Tag: Perfect

A Perfect Table of Contents With HTML + CSS

Earlier this year, I self-published an ebook called Understanding JavaScript Promises (free for download). Even though I didn’t have any intention of turning it into a print book, enough people reached out inquiring about a print version that I decided to self-publish that as well .I thought it would be an easy exercise using HTML and CSS to generate a PDF and then send it off to the printer. What I didn’t realize was that I didn’t have an answer to an important part of a print book: the table of contents.

The makeup of a table of contents

At its core, a table of contents is fairly simple. Each line represents a part of a book or webpage and indicates where you can find that content. Typically, the lines contain three parts:

  1. The title of the chapter or section
  2. Leaders (i.e. those dots, dashes, or lines) that visually connect the title to the page number
  3. The page number

A table of contents is easy to generate inside of word processing tools like Microsoft Word or Google Docs, but because my content was in Markdown and then transformed into HTML, that wasn’t a good option for me. I wanted something automated that would work with HTML to generate the table of contents in a format that was suitable for print. I also wanted each line to be a link so it could be used in webpages and PDFs to navigate around the document. I also wanted dot leaders between the title and page number.

And so I began researching.

I came across two excellent blog posts on creating a table of contents with HTML and CSS. The first was “Build a Table of Contents from your HTML” by Julie Blanc. Julie worked on PagedJS, a polyfill for missing paged media features in web browsers that properly formats documents for print. I started with Julie’s example, but found that it didn’t quite work for me. Next, I found Christoph Grabo’s “Responsive TOC leader lines with CSS” post, which introduced the concept of using CSS Grid (as opposed to Julie’s float-based approach) to make alignment easier. Once again, though, his approach wasn’t quite right for my purposes.

After reading these two posts, though, I felt I had a good enough understanding of the layout issues to embark on my own. I used pieces from both blog posts as well as adding some new HTML and CSS concepts into the approach to come up with a result I’m happy with.

Choosing the correct markup

When deciding on the correct markup for a table of contents, I thought primarily about the correct semantics. Fundamentally, a table of contents is about a title (chapter or subsection) being tied to a page number, almost like a key-value pair. That led me to two options:

  • One option is to use a table (<table>) with one column for the title and one column for the page.
  • Then there’s the often unused and forgotten definition list (<dl>) element. It also acts as a key-value map. So, once again, the relationship between the title and the page number would be obvious.

Either of these seemed like good options until I realized that they really only work for single-level tables of contents, namely, only if I wanted to have a table of contents with just chapter names. If I wanted to show subsections in the table of contents, though, I didn’t have any good options. Table elements aren’t great for hierarchical data, and while definition lists can technically be nested, the semantics didn’t seem correct. So, I went back to the drawing board.

I decided to build off of Julie’s approach and use a list; however, I opted for an ordered list (<ol>) instead of an unordered list (<ul>). I think an ordered list is more appropriate in this case. A table of contents represents a list of chapters and subheadings in the order in which they appear in the content. The order matters and shouldn’t get lost in the markup.

Unfortunately, using an ordered list means losing the semantic relationship between the title and the page number, so my next step was to re-establish that relationship within each list item. The easiest way to solve this is to simply insert the word “page” before the page number. That way, the relationship of the number relative to the text is clear, even without any other visual distinction.

Here’s a simple HTML skeleton that formed the basis of my markup:

<ol class="toc-list">   <li>     <a href="#link_to_heading">       <span class="title">Chapter or subsection title</span>       <span class="page">Page 1</span>     </a>      <ol>       <!-- subsection items -->     </ol>   </li> </ol>

Applying styles to the table of contents

Once I had established the markup I planned to use, the next step was to apply some styles.

First, I removed the autogenerated numbers. You can choose to keep the autogenerated numbers in your own project if you’d like, but it’s common for books to have unnumbered forewords and afterwords included in the list of chapters, which makes the autogenerated numbers incorrect.

For my purpose, I would fill in the chapter numbers manually then adjust the layout so the top-level list doesn’t have any padding (thus aligning it with paragraphs) and each embedded list is indented by two spaces. I chose to use a 2ch padding value because I still wasn’t quite sure which font I would use. The ch length unit allows the padding to be relative to the width of a character — no matter what font is used — rather than an absolute pixel size that could wind up looking inconsistent.

Here’s the CSS I ended up with:

.toc-list, .toc-list ol {   list-style-type: none; }  .toc-list {   padding: 0; }  .toc-list ol {   padding-inline-start: 2ch; }

Sara Soueidan pointed out to me that WebKit browsers remove list semantics when list-style-type is none, so I needed to add role="list" into the HTML to preserve it:

<ol class="toc-list" role="list">   <li>     <a href="#link_to_heading">       <span class="title">Chapter or subsection title</span>       <span class="page">Page 1</span>     </a>      <ol role="list">       <!-- subsection items -->     </ol>   </li> </ol>

Styling the title and page number

With the list styled to my liking, it was time to move on to styling an individual list item. For each item in the table of contents, the title and page number must be on the same line, with the title to the left and the page number aligned to the right.

You might be thinking, “No problem, that’s what flexbox is for!” You aren’t wrong! Flexbox can indeed achieve the correct title-page alignment. But there are some tricky alignment issues when the leaders are added, so I instead opted to go with Christoph’s approach using a grid, which as a bonus as it also helps with multiline titles. Here is the CSS for an individual item:

.toc-list li > a {   text-decoration: none;   display: grid;   grid-template-columns: auto max-content;   align-items: end; }  .toc-list li > a > .page {   text-align: right; }

The grid has two columns, the first of which is auto-sized to fill up the entire width of the container, minus the second column, which is sized to max-content. The page number is aligned to the right, as is traditional in a table of contents.

The only other change I made at this point was to hide the “Page” text. This is helpful for screen readers but unnecessary visually, so I used a traditional visually-hidden class to hide it from view:

.visually-hidden {   clip: rect(0 0 0 0);   clip-path: inset(100%);   height: 1px;   overflow: hidden;   position: absolute;   width: 1px;   white-space: nowrap; }

And, of course, the HTML needs to be updated to use that class:

<ol class="toc-list" role="list">   <li>     <a href="#link_to_heading">       <span class="title">Chapter or subsection title</span>       <span class="page"><span class="visually-hidden">Page</span> 1</span>     </a>      <ol role="list">       <!-- subsection items -->     </ol>   </li> </ol>

With this foundation in place, I moved on to address the leaders between the title and the page.

Creating dot leaders

Leaders are so common in print media that you might be wondering, why doesn’t CSS already support that? The answer is: it does. Well, kind of.

There is actually a leader() function defined in the CSS Generated Content for Paged Media specification. However, as with much of the paged media specifications, this function isn’t implemented in any browsers, therefore excluding it as an option (at least at the time I’m writing this). It’s not even listed on caniuse.com, presumably because no one has implemented it and there are no plans or signals that they will.

Fortunately, both Julie and Christoph already addressed this problem in their respective posts. To insert the dot leaders, they both used a ::after pseudo-element with its content property set to a very long string of dots, like this:

.toc-list li > a > .title {   position: relative;   overflow: hidden; }  .toc-list li > a .title::after {   position: absolute;   padding-left: .25ch;   content: " . . . . . . . . . . . . . . . . . . . "       ". . . . . . . . . . . . . . . . . . . . . . . "       ". . . . . . . . . . . . . . . . . . . . . . . "       ". . . . . . . . . . . . . . . . . . . . . . . "       ". . . . . . . . . . . . . . . . . . . . . . . "       ". . . . . . . . . . . . . . . . . . . . . . . "       ". . . . . . . . . . . . . . . . . . . . . . . ";   text-align: right; }

The ::after pseudo-element is set to an absolute position to take it out of the flow of the page and avoid wrapping to other lines. The text is aligned to the right because we want the last dots of each line flush to the number at the end of the line. (More on the complexities of this later.) The .title element is set to have a relative position so the ::after pseudo-element doesn’t break out of its box. Meanwhile, the overflow is hidden so all those extra dots invisible. The result is a pretty table of contents with dot leaders.

However, there’s something else that needs consideration.

Sara also pointed out to me that all of those dots count as text to screen readers. So what do you hear? “Introduction dot dot dot dot…” until all of the dots are announced. That’s an awful experience for screen reader users.

The solution is to insert an additional element with aria-hidden set to true and then use that element to insert the dots. So the HTML becomes:

<ol class="toc-list" role="list">   <li>     <a href="#link_to_heading">       <span class="title">Chapter or subsection title<span class="leaders" area-hidden="true"></span></span>       <span class="page"><span class="visually-hidden">Page</span> 1</span>     </a>      <ol role="list">       <!-- subsection items -->     </ol>   </li> </ol>

And the CSS becomes:

.toc-list li > a > .title {   position: relative;   overflow: hidden; }  .toc-list li > a .leaders::after {   position: absolute;   padding-left: .25ch;   content: " . . . . . . . . . . . . . . . . . . . "       ". . . . . . . . . . . . . . . . . . . . . . . "       ". . . . . . . . . . . . . . . . . . . . . . . "       ". . . . . . . . . . . . . . . . . . . . . . . "       ". . . . . . . . . . . . . . . . . . . . . . . "       ". . . . . . . . . . . . . . . . . . . . . . . "       ". . . . . . . . . . . . . . . . . . . . . . . ";   text-align: right; }

Now screen readers will ignore the dots and spare users the frustration of listening to multiple dots being announced.

Finishing touches

At this point, the table of contents component looks pretty good, but it could use some minor detail work. To start, most books visually offset chapter titles from subsection titles, so I made the top-level items bold and introduced a margin to separate subsections from the chapters that followed:

.toc-list > li > a {   font-weight: bold;   margin-block-start: 1em; }

Next, I wanted to clean up the alignment of the page numbers. Everything looked okay when I was using a fixed-width font, but for variable-width fonts, the leader dots could end up forming a zigzag pattern as they adjust to the width of a page number. For instance, any page number with a 1 would be narrower than others, resulting in leader dots that are misaligned with the dots on previous or following lines.

Misaligned numbers and dots in a table of contents.

To fix this problem, I set font-variant-numeric to tabular-nums so all numbers are treated with the same width. By also setting the minimum width to 2ch, I ensured that all numbers with one or two digits are perfectly aligned. (You may want to set this to 3ch if your project has more than 100 pages.) Here is the final CSS for the page number:

.toc-list li > a > .page {   min-width: 2ch;   font-variant-numeric: tabular-nums;   text-align: right; }
Aligned leader dots in a table of contents.

And with that, the table of contents is complete!

Conclusion

Creating a table of contents with nothing but HTML and CSS was more of a challenge than I expected, but I’m very happy with the result. Not only is this approach flexible enough to accommodate chapters and subsections, but it handles sub-subsections nicely without updating the CSS. The overall approach works on web pages where you want to link to the various locations of content, as well as PDFs where you want the table of contents to link to different pages. And of course, it also looks great in print if you’re ever inclined to use it in a brochure or book.

I’d like to thank Julie Blanc and Christoph Grabo for their excellent blog posts on creating a table of contents, as both of those were invaluable when I was getting started. I’d also like to thank Sara Soueidan for her accessibility feedback as I worked on this project.


A Perfect Table of Contents With HTML + CSS originally published on CSS-Tricks. You should get the newsletter.

CSS-Tricks

, , ,

Creating the Perfect Commit in Git

This article is part of our “Advanced Git” series. Be sure to follow Tower on Twitter or sign up for the Tower newsletter to hear about the next articles!

A commit in Git can be one of two things:

  • It can be a jumbled assortment of changes from all sorts of topics: some lines of code for a bugfix, a stab at rewriting an old module, and a couple of new files for a brand new feature.
  • Or, with a little bit of care, it can be something that helps us stay on top of things. It can be a container for related changes that belong to one and only one topic, and thereby make it easier for us to understand what happened.

In this post, we’re talking about what it takes to produce the latter type of commit or, in other words: the “perfect” commit.

Advanced Git series:

  • Part 1: Creating the Perfect Commit in Git
    You are here!
  • Part 2: Branching Strategies in Git
    Coming soon!
  • Part 3: Better Collaboration With Pull Requests
  • Part 4: Merge Conflicts
  • Part 5: Rebase vs. Merge
  • Part 6: Interactive Rebase
  • Part 7: Cherry-Picking Commits in Git
  • Part 8: Using the Reflog to Restore Lost Commits

Why clean and granular commits matter

Is it really necessary to compose commits in a careful, thoughtful way? Can’t we just treat Git as a boring backup system? Let’s revisit our example from above one more time.

If we follow the first path – where we just cram changes into commits whenever they happen – commits lose much of their value. The separation between one commit and the next becomes arbitrary: there seems to be no reason why changes were put into one and not the other commit. Looking at these commits later, e.g. when your colleagues try to make sense of what happened in that revision, is like going through the “everything drawer” that every household has: there’s everything in here that found no place elsewhere, from crayons to thumbtacks and cash slips. It’s terribly hard to find something in these drawers!

Following the second path – where we put only things (read: changes) that belong together into the same commit – requires a bit more planning and discipline. But in the end, you and your team are rewarded with something very valuable: a clean commit history! These commits help you understand what happened. They help explain the complex changes that were made in a digestible manner.

How do we go about creating better commits?

Composing better commits

One concept is central to composing better commits in Git: the Staging Area.

The Staging Area was made exactly for this purpose: to allow developers to select changes – in a very granular way – that should be part of the next commit. And, unlike other version control systems, Git forces you to make use of this Staging Area.

Unfortunately, however, it’s still easy to ignore the tidying effect of the Staging Area: a simple git add . will take all of our current local changes and mark them for the next commit.

It’s true that this can be a very helpful and valid approach sometimes. But many times, we would be better off stopping for a second and deciding if really all of our changes are actually about the same topic. Or if two or three separate commits might be a much better choice. 

In most cases, it makes a lot of sense to keep commits rather smaller than larger. Focused on an individual topic (instead of two, three, or four), they tend to be much more readable.

The Staging Area allows us to carefully pick each change that should go into the next commit: 

$  git add file1.ext file2.ext

This will only mark these two files for the next commit and leave the other changes for a future commit or further edits.

This simple act of pausing and deliberately choosing what should make it into the next commit goes a long way. But we can get even more precise than that. Because sometimes, even the changes in a single file belong to multiple topics.

Let’s look at a real-life example and take a look at the exact changes in our “index.html” file. We can either use the “git diff” command or a Git desktop GUI like Tower:

Now, we can add the -p option to git add:

$  git add -p index.html

We’re instructing Git to go through this file on a “patch” level: Git takes us by the hand and walks us through all of the changes in this file. And it asks us, for each chunk, if we want to add it to the Staging Area or not:

By typing [Y] (for “yes”) for the first chunk and [N] (for “no”) for the second chunk, we can include the first part of our changes in this file in the next commit, but leave the other changes for a later time or more edits.

The result? A more granular, more precise commit that’s focused on a single topic.

Testing your code

Since we’re talking about “the perfect commit” here, we cannot ignore the topic of testing. How exactly you “test” your code can certainly vary, but the notion that tests are important isn’t new. In fact, many teams refuse to consider a piece of code completed if it’s not properly tested.

If you’re still on the fence about whether you should test your code or not, let’s debunk a couple of myths about testing:

  • “Tests are overrated”: The fact is that tests help you find bugs more quickly. Most importantly, they help you find them before something goes into production – which is when mistakes hurt the most. And finding bugs early is, without exaggeration, priceless!
  • “Tests cost valuable time”: After some time you will find that well-written tests make you write code faster. You waste less time hunting bugs and find that, more often, a well-structured test primes your thinking for the actual implementation, too.
  • “Testing is complicated”: While this might have been an argument a couple of years ago, this is now untrue. Most professional programming frameworks and languages come with extensive support for setting up, writing, and managing tests.

All in all, adding tests to your development habits is almost guaranteed to make your code base more robust. And, at the same time, they help you become a better programmer.

A valuable commit message

Version control with Git is not a fancy way of backing up your code. And, as we’ve already discussed, commits are not a dump of arbitrary changes. Commits exist to help you and your teammates understand what happened in a project. And a good commit message goes a long way to ensure this.

But what makes a good commit message?

  • A brief and concise subject line that summarizes the changes
  • A descriptive message body that explains the most important facts (and as concisely as possible)

Let’s start with the subject line: the goal is to get a brief summary of what happened. Brevity, of course, is a relative term; but the general rule of thumb is to (ideally) keep the subject under 50 characters. By the way, if you find yourself struggling to come up with something brief, this might be an indicator that the commit tackles too many topics! It could be worthwhile to take another look and see if you have to split it into multiple, separate ones.

If you close the subject with a line break and an additional empty line, Git understands that the following text is the message’s “body.” Here, you have more space to describe what happened. It helps to keep the following questions in mind, which your body text should aim to answer:

  • What changed in your project with this commit?
  • What was the reason for making this change?
  • Is there anything special to watch out for? Anything someone else should know about these changes?

If you keep these questions in mind when writing your commit message body, you are very likely to produce a helpful description of what happened. And this, ultimately, benefits your colleagues (and after some time: you) when trying to understand this commit.

On top of the rules I just described about the content of commit messages, many teams also care about the format: agreeing on character limits, soft or hard line wraps, etc. all help to produce better commits within a team. 

To make it easier to stick by such rules, we recently added some features to Tower, the Git desktop GUI that we make: you can now, for example, configure character counts or automatic line wraps just as you like.

A great codebase consists of great commits

Any developer will admit that they want a great code base. But there’s only one way to achieve this lofty goal: by consistently producing great commits! I hope I was able to demonstrate that (a) it’s absolutely worth pursuing this goal and (b) it’s not that hard to achieve.

If you want to dive deeper into advanced Git tools, feel free to check out my (free!) “Advanced Git Kit”: it’s a collection of short videos about topics like branching strategies, Interactive Rebase, Reflog, Submodules and much more.

Have fun creating awesome commits!

Advanced Git series:

  • Part 1: Creating the Perfect Commit in Git
    You are here!
  • Part 2: Branching Strategies in Git
    Coming soon!
  • Part 3: Better Collaboration With Pull Requests
  • Part 4: Merge Conflicts
  • Part 5: Rebase vs. Merge
  • Part 6: Interactive Rebase
  • Part 7: Cherry-Picking Commits in Git
  • Part 8: Using the Reflog to Restore Lost Commits


The post Creating the Perfect Commit in Git appeared first on CSS-Tricks. You can support CSS-Tricks by being an MVP Supporter.

CSS-Tricks

, ,
[Top]

Perfect Tooltips With CSS Clipping and Masking

Clipping and masking have been around for a while now in CSS and even have pretty decent browser support. I recently worked on a project that needed to use a clipping technique for tooltips showing above links in text.

Those tooltips have two designs based on their content:

One design is a tooltip that contains plain text against a solid background.

The other design allows an image to cover the entire space.

You might not think the text tooltip requires any clipping at all. A pseudo-element can be positioned at the bottom to add the little notch, right? You are indeed absolutely right! Because the background of the tooltip is a a plain color, there’s really no need for CSS trickery and whatnot.

But clipping the image in the second design is where things get interesting…

Here’s the thought process my mind followed when I started the task.

Idea 1: clip-path & polygon

The CSS clip-path property allows us to define a custom polygon with percentage values to make the path we want.
This solution is often enough if the shape of your path is simple enough. In the demo below, I’m using calc() values to make sure the clip is fully responsive, while the little triangle stays the same size no matter how stretched the parent is.

.tooltip {   clip-path: polygon(     0% 0%, // Top left point     100% 0%, // Top right point     100% calc(100% - 10px), // Bottom right point     calc(50% + 10px) calc(100% - 10px), // Center right of the triangle     50% 100%, // Tip of the triangle     calc(50% - 10px) calc(100% - 10px), // Center left of the triangle     0% calc(100% - 10px) // Bottom left point   ); }

This solution is very clean but, in my case, not good enough as I don’t have a straight triangle notch, but rather a custom shape.

Idea 2: clip-path and SVG

Using an SVG path seemed like a good solution. First, you export your SVG clipping path, then use it in your CSS with the url(#clipPathId) value.

Check the demo below. Do you see any issue with the path?

The arrow is stretched based on the image ratio. Since the little notch is part of the whole path shape, it is as stretched as the rectangle part of the path stretches in size.

Idea 3: mask-image

Now here is the thing I discovered with the CSS mask-image property in CSS: You can combine mask layers! Think about it like a background-image in CSS. You can apply multiple gradients or images on a single element. Now, what if you combine all those layers to generate the final mask you need?

This is exactly what we are going to do here with two layers:

  1. A large rectangle that cover the whole block except for a stripe at the bottom (shown in green)
  2. An image of the arrow (shown in pink)

With that solution, the rectangle can stretch according to our tooltip’s dimensions, and the arrow will always keep its fixed size.

All the code and demos below are prefix free and the demos are using Autoprefixer. At the time I’m writing this article, Edge, Chrome & Safari require prefixes.

Just as we would with background properties, we are going to use three different mask properties to define our two layers:

  • mask-image: This property lets us draw the rectangle with a linear background and the arrow with an inline SVG.
  • mask-position: The rectangle doesn’t need a position (as it starts from the top-left), but the arrow needs to be positioned at the center-bottom.
  • mask-repeat: We need to avoid repeating both layers; otherwise, the linear gradient would cover the whole element when it repeats.
.tooltip {   mask-image:     linear-gradient(#fff, #fff), /* Rectangle */     url('data:image/svg+xml;utf8,'); /* Bottom arrow mask-position: */     0 0, /* Rectangle */     50% 100%; /* Bottom arrow */   mask-size:     100% calc(100% - 18px), /* Rectangle */     38px 18px; /* Bottom arrow */   mask-repeat: no-repeat; }

Tada! Change the tooltip dimensions or replace the image and the bottom arrow will keep its original ratio.

More complex shapes

Let’s get a little fancy and go deeper with this technique. I was inspired by the iMessage app on iOS and tried to reproduce the same tooltips with this masking technique.

I had to draw more layers for my mask to render every rounded corner:

  • four circles, one for each corner (shown in red)
  • one horizontal rectangle (shown in blue)
  • one vertical rectangle (shown in green)
  • one SVG for the arrow (shown in yellow)

The full code is going to be a bit longer as we have more layers to draw, but the logic stays the same. The corners are drawn using four radial gradients. To fill the rectangle, we need two rectangles (one vertical, one horizontal) as shown above. And finally, our little arrow that is using an inline SVG.

.tooltip {   --radius: 25px;   mask-image:     radial-gradient(#fff (var(--radius) - 1), #fff0 var(--radius)), /* Top left corner */     radial-gradient(#fff (var(--radius) - 1), #fff0 var(--radius)), /* Top right corner */     radial-gradient(#fff (var(--radius) - 1), #fff0 var(--radius)), /* Bottom left corner */     radial-gradient(#fff (var(--radius) - 1), #fff0 var(--radius)), /* Bottom right corner */     linear-gradient(#fff, #fff), /* Horizontal gradient */     linear-gradient(#fff, #fff), /* Vertical gradient */     url('data:image/svg+xml;utf8,'); /* Bottom right icon */   mask-position:      0 0, /* Top left corner */     100% 0, /* Top right corner */     0 100%, /* Bottom left corner */     100% 100%, /* Bottom right corner */     0 var(--radius), /* Horizontal gradient */     var(--radius) 0, /* Vertical gradient */     100% 100%; /* Bottom right icon */   mask-size:     (var(--radius) * 2) (var(--radius) * 2),  /* Top left corner */     (var(--radius) * 2) (var(--radius) * 2),  /* Top right corner */     (var(--radius) * 2) (var(--radius) * 2),  /* Bottom left corner */     (var(--radius) * 2) (var(--radius) * 2),  /* Bottom right corner */     100% calc(100% - #{var(--radius) * 2}), /* Horizontal gradient */     calc(100% - #{var(--radius) * 2}) 100%, /* Vertical gradient */     (39px / 2) (25px / 2); /* Bottom right icon */   mask-repeat: no-repeat; }

As you see, we can create a version with the arrow on the left or right by using a flipped version of the arrow and positioning it in a different corner. The trick is working fine on tooltips without images too. But like I said at the beginning of this article, you probably don’t need that much CSS if you only have a plain background to style.


If you want to learn more about clipping and masking in CSS, there are lots of other great articles right here on CSS-Tricks worth checking out.


The post Perfect Tooltips With CSS Clipping and Masking appeared first on CSS-Tricks.

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

CSS-Tricks

, , ,
[Top]

Going “Meta GSAP”: The Quest for “Perfect” Infinite Scrolling

I‘m not sure how this one came about. But, it‘s a story. This article is more about grokking a concept, one that’s going to help you think about your animations in a different way. It so happens that this particular example features infinite scrolling — specifically the “perfect” infinite scroll for a deck of cards without duplicating any of them.

Why am I here? Well, this all started from a tweet. A tweet that got me thinking about layouts and side-scrolling content.

I took that concept and used it on my site. And it’s still there in action at the time of writing.

Then I got to thinking more about gallery views and side-scrolling concepts. We hopped on a livestream and decided to try and make something like the old Apple “Cover Flow” pattern. Remember it?

My first thoughts for making this assumed I‘d make this so it works without JavaScript, as it does in the demo above, in a way that uses “progressive enhancement.” I grabbed Greensock and ScrollTrigger, and off we went. I came away from that work pretty disappointed. I had something but couldn‘t quite get infinite scrolling to work how the way I wanted. The “Next” and “Previous” buttons didn’t want to play ball. You can see it here, and it requires horizontal scrolling.

So I opened up a new thread on the Greensock forum. Little did I know I was about to open myself up to some serious learning! We solved the issue with the buttons. But, being me, I had to ask whether something else was possible. Was there a “clean” way to do infinite scrolling? I‘d tried something on stream but had no luck. I was curious. I’d tried a technique like that used in this pen which I created for the ScrollTrigger release.

The initial answer was that it is kinda tricky to do:

The hard part about infinite things on scroll is that the scroll bar is limited while the effect that you’re wanting is not. So you have to either loop the scroll position like this demo (found in the ScrollTrigger demos section) or hook directly into the scroll-related navigation events (like the wheel event) instead of actually using the actual scroll position.

I figured that was the case and was happy to leave it “as-is.” A couple of days passed and Jack dropped a reply that kinda blew my mind when I started digging into it. And now, after a bunch of going through it, I’m here to share the technique with you.

Animate anything

One thing that is often overlooked with GSAP, is that you can animate almost anything with it. This is often because visual things are what spring to mind when thinking about animation — the actual physical movement of something. Our first thought isn’t about taking that process to a meta-level and animating from a step back.

But, think about animation work on a larger scale and then break it down into layers. For example, you play a cartoon. The cartoon is a collection of compositions. Each composition is a scene. And then you have the power to scrub through that collection of compositions with a remote, whether it’s on YouTube, using your TV remote, or whatever. There are almost three levels to what is happening.

And this is the trick we need for creating different types of infinite loops. This is the main concept right here. We animate the play head position of a timeline with a timeline. And then we can scrub that timeline with our scroll position.

Don‘t worry if that sounds confusing. We’re going to break it down.

Going “meta”

Let‘s start with an example. We’re going to create a tween that moves some boxes from left to right. Here it is.

Ten boxes that keep going left to right. That’s quite straightforward with Greensock. Here, we use fromTo and repeat to keep the animation going. But, we have a gap at the start of each iteration. We’re also using stagger to space out the movement and that’s something that will play an important role as we continue.

gsap.fromTo('.box', {   xPercent: 100 }, {   xPercent: -200,   stagger: 0.5,   duration: 1,   repeat: -1,   ease: 'none', })

Now comes the fun part. Let’s pause the tween and assign it to a variable. Then let’s create a tween that plays it. We can do this by tweening the totalTime of the tween, which allows us to get or set the tween’s playhead tween, while considering repeats and repeat delays.

const SHIFT = gsap.fromTo('.box', {   xPercent: 100 }, {   paused: true,   xPercent: -200,   stagger: 0.5,   duration: 1,   repeat: -1,   ease: 'none', })  const DURATION = SHIFT.duration()  gsap.to(SHIFT, {   totalTime: DURATION,   repeat: -1,   duration: DURATION,   ease: 'none', })

This is our first “meta” tween. It looks exactly the same but we’re adding another level of control. We can change things on this layer without affecting the original layer. For example, we could change the tween ease to power4.in. This completely changes the animation but without affecting the underlying animation. We’re kinda safeguarding ourselves with a fallback.

Not only that, we might choose to repeat only a certain part of the timeline. We could do that with another fromTo, like this:

The code for that would be something like this.

gsap.fromTo(SHIFT, {   totalTime: 2, }, {   totalTime: DURATION - 1,   repeat: -1,   duration: DURATION,   ease: 'none' })

Do you see where this is going? Watch that tween. Although it keeps looping, the numbers flip on each repeat. But, the boxes are in the correct position.

Achieving the “perfect” loop

If we go back to our original example, there’s a noticeable gap between each repetition.

Here comes the trick. The part that unlocks everything. We need to build a perfect loop.

Let‘s start by repeating the shift three times. It’s equal to using repeat: 3. Notice how we’ve removed repeat: -1 from the tween.

const getShift = () => gsap.fromTo('.box', {   xPercent: 100 }, {   xPercent: -200,   stagger: 0.5,   duration: 1,   ease: 'none', })  const LOOP = gsap.timeline()   .add(getShift())   .add(getShift())   .add(getShift())

We’ve turned the initial tween into a function that returns the tween and we add it to a new timeline three times. And this gives us the following.

OK. But, there’s still a gap. Now we can bring in the position parameter for adding and positioning those tweens. We want it to be seamless. That means inserting each each set of tweens before the previous one ends. That’s a value based on the stagger and the amount of elements.

const stagger = 0.5 // Used in our shifting tween const BOXES = gsap.utils.toArray('.box') const LOOP = gsap.timeline({   repeat: -1 })   .add(getShift(), 0)   .add(getShift(), BOXES.length * stagger)   .add(getShift(), BOXES.length * stagger * 2)

If we update our timeline to repeat and watch it (while adjusting the stagger to see how it affects things)…

You‘ll notice that there‘s a window in the middle there that creates a “seamless” loop. Recall those skills from earlier where we manipulated time? That’s what we need to do here: loop the window of time where the loop is “seamless.”

We could try tweening the totalTime through that window of the loop.

const LOOP = gsap.timeline({   paused: true,   repeat: -1, }) .add(getShift(), 0) .add(getShift(), BOXES.length * stagger) .add(getShift(), BOXES.length * stagger * 2)  gsap.fromTo(LOOP, {   totalTime: 4.75, }, {   totalTime: '+=5',   duration: 10,   ease: 'none',   repeat: -1, }) 

Here, we’re saying tween the totalTime from 4.75 and add the length of a cycle to that. The length of a cycle is 5. And that’s the middle window of the timeline. We can use GSAP’s nifty += to do that, which gives us this:

Take a moment to digest what‘s happening there. This could be the trickiest part to wrap your head around. We’re calculating windows of time in our timeline. It’s kinda hard to visualize but I’ve had a go.

This is a demo of a watch that takes 12 seconds for the hands go round once. It‘s looped infinitely with repeat: -1 and then we‘re using fromTo to animate a specific time window with a given duration. If you, reduce the time window to say 2 and 6, then change the duration to 1, the hands will go from 2 o‘clock to 6 o’clock on repeat. But, we never changed the underlying animation.

Try configuring the values to see how it affects things.

At this point, it’s a good idea to put together a formula for our window position. We could also use a variable for the duration it takes for each box to transition.

const DURATION = 1 const CYCLE_DURATION = BOXES.length * STAGGER const START_TIME = CYCLE_DURATION + (DURATION * 0.5) const END_TIME = START_TIME + CYCLE_DURATION

Instead of using three stacked timelines, we could loop over our elements three times where we get the benefit of not needing to calculate the positions. Visualizing this as three stacked timelines is a neat way to grok the concept, though, and a nice way to help understand the main idea.

Let’s change our implementation to create one big timeline from the start.

const STAGGER = 0.5 const BOXES = gsap.utils.toArray('.box')  const LOOP = gsap.timeline({   paused: true,   repeat: -1, })  const SHIFTS = [...BOXES, ...BOXES, ...BOXES]  SHIFTS.forEach((BOX, index) => {   LOOP.fromTo(BOX, {     xPercent: 100   }, {     xPercent: -200,     duration: 1,     ease: 'none',   }, index * STAGGER) })

This is easier to put together and gives us the same window. But, we don’t need to think about math. Now we loop through three sets of the boxes and position each animation according to the stagger.

How might that look if we adjust the stagger? It will squish the boxes closer together.

But, it’s broken the window because now the totalTime is out. We need to recalculate the window. Now’s a good time to plug in the formula we calculated earlier.

const DURATION = 1 const CYCLE_DURATION = STAGGER * BOXES.length const START_TIME = CYCLE_DURATION + (DURATION * 0.5) const END_TIME = START_TIME + CYCLE_DURATION  gsap.fromTo(LOOP, {   totalTime: START_TIME, }, {   totalTime: END_TIME,   duration: 10,   ease: 'none',   repeat: -1, })

Fixed!

We could even introduce an “offset” if we wanted to change the starting position.

const STAGGER = 0.5 const OFFSET = 5 * STAGGER const START_TIME = (CYCLE_DURATION + (STAGGER * 0.5)) + OFFSET

Now our window starts from a different position.

But still, this isn’t great as it gives us these awkward stacks at each end. To get rid of that effect, we need to think about a “physical” window for our boxes. Or think about how they enter and exit the scene.

We’re going to use document.body as the window for our example. Let’s update the box tweens to be individual timelines where the boxes scale up on enter and down on exit. We can use yoyo and repeat: 1 to achieve entering and exiting.

SHIFTS.forEach((BOX, index) => {   const BOX_TL = gsap     .timeline()     .fromTo(       BOX,       {         xPercent: 100,       },       {         xPercent: -200,         duration: 1,         ease: 'none',       }, 0     )     .fromTo(       BOX,       {         scale: 0,       },       {         scale: 1,         repeat: 1,         yoyo: true,         ease: 'none',         duration: 0.5,       },       0     )   LOOP.add(BOX_TL, index * STAGGER) })

Why are we using a timeline duration of 1? It makes things easier to follow. We know the time is 0.5 when the box is at the midpoint. It‘s worth noting that easing won’t have the effect we usually think of here. In fact, easing will actually play a part in how the boxes position themselves. For example, an ease-in would bunch the boxes up on the right before they move across.

The code above gives us this.

Almost. But, our boxes disappear for a time in the middle. To fix this, let’s introduce the immediateRender property. It acts like animation-fill-mode: none in CSS. We’re telling GSAP that we don’t want to retain or pre-record any styles that are being set on a box.

SHIFTS.forEach((BOX, index) => {   const BOX_TL = gsap     .timeline()     .fromTo(       BOX,       {         xPercent: 100,       },       {         xPercent: -200,         duration: 1,         ease: 'none',         immediateRender: false,       }, 0     )     .fromTo(       BOX,       {         scale: 0,       },       {         scale: 1,         repeat: 1,         zIndex: BOXES.length + 1,         yoyo: true,         ease: 'none',         duration: 0.5,         immediateRender: false,       },       0     )   LOOP.add(BOX_TL, index * STAGGER) })

That small change fixes things for us! Note how we’ve also included z-index: BOXES.length. That should safeguard us against any z-index issues.

There we have it! Our first infinite seamless loop. No duplicate elements and perfect continuation. We’re bending time! Pat yourself on the back if you’ve gotten this far! 🎉

If we want to see more boxes at once, we can tinker with the timing, stagger, and ease. Here, we have a STAGGER of 0.2 and we’ve also introduced opacity into the mix.

The key part here is that we can make use of repeatDelay so that the opacity transition is quicker than the scale. Fade in over 0.25 seconds. Wait 0.5 seconds. Fade back out over 0.25 seconds.

.fromTo(   BOX, {     opacity: 0,   }, {     opacity: 1,     duration: 0.25,     repeat: 1,     repeatDelay: 0.5,     immediateRender: false,     ease: 'none',     yoyo: true,   }, 0)

Cool! We could do whatever we want with those in and out transitions. The main thing here is that we have our window of time that gives us the infinite loop.

Hooking this up to scroll

Now that we have a seamless loop, let’s attach it to scroll. For this we can use GSAP’s ScrollTrigger. This requires an extra tween to scrub our looping window. Note how we’ve set the loop to be paused now, too.

const LOOP_HEAD = gsap.fromTo(LOOP, {   totalTime: START_TIME, }, {   totalTime: END_TIME,   duration: 10,   ease: 'none',   repeat: -1,   paused: true, })  const SCRUB = gsap.to(LOOP_HEAD, {   totalTime: 0,   paused: true,   duration: 1,   ease: 'none', })

The trick here is to use ScrollTrigger to scrub the play head of the loop by updating the totalTime of SCRUB. There are various ways we could set up this scroll. We could have it horizontal or bound to a container. But, what we‘re going to do is wrap our boxes in a .boxes element and pin that to the viewport. (This fixes its position in the viewport.) We’ll also stick with vertical scrolling. Check the demo to see the styling for .boxes which sets things to the size of the viewport.

import ScrollTrigger from 'https://cdn.skypack.dev/gsap/ScrollTrigger' gsap.registerPlugin(ScrollTrigger)  ScrollTrigger.create({   start: 0,   end: '+=2000',   horizontal: false,   pin: '.boxes',   onUpdate: self => {     SCRUB.vars.totalTime = LOOP_HEAD.duration() * self.progress     SCRUB.invalidate().restart()   } })

The important part is inside onUpdate. That’s where we set the totalTime of the tween based on the scroll progress. The invalidate call flushes any internally recorded positions for the scrub. The restart then sets the position to the new totalTime we set.

Try it out! We can go back and forth in the timeline and update the position.

How cool is that? We can scroll to scrub a timeline that scrubs a timeline that is a window of a timeline. Digest that for a second because that‘s what’s happening here.

Time travel for infinite scrolling

Up to now, we‘ve been manipulating time. Now we’re going to time travel!

To do this, we‘re going to use some other GSAP utilities and we‘re no longer going to scrub the totalTime of LOOP_HEAD. Instead, we’re going to update it via proxy. This is another great example of going “meta” GSAP.

Let’s start with a proxy object that marks the playhead position.

const PLAYHEAD = { position: 0 }

Now we can update our SCRUB to update the position. At the same time, we can use GSAP’s wrap utility, which wraps the position value around the LOOP_HEAD duration. For example, if the duration is 10 and we provide the value 11, we will get back 1.

const POSITION_WRAP = gsap.utils.wrap(0, LOOP_HEAD.duration())  const SCRUB = gsap.to(PLAYHEAD, {   position: 0,   onUpdate: () => {     LOOP_HEAD.totalTime(POSITION_WRAP(PLAYHEAD.position))   },   paused: true,   duration: 1,   ease: 'none', })

Last, but not least, we need to revise ScrollTrigger so it updates the correct variable on the SCRUB. That’s position, instead of totalTime.

ScrollTrigger.create({   start: 0,   end: '+=2000',   horizontal: false,   pin: '.boxes',   onUpdate: self => {     SCRUB.vars.position = LOOP_HEAD.duration() * self.progress     SCRUB.invalidate().restart()   } })

At this point we’ve switched to a proxy and we won’t see any changes.

We want an infinite loop when we scroll. Our first thought might be to scroll to the start when we complete scroll progress. And it would do exactly that, scroll back. Although that‘s what we want to do, we don’t want the playhead to scrub backwards. This is where totalTime comes in. Remember? It gets or sets the position of the playhead according to the totalDuration which includes any repeats and repeat delays.

For example, say the duration of the loop head was 5 and we got there, we won‘t scrub back to 0. Instead, we will keep scrubbing the loop head to 10. If we keep going, it‘ll go to 15, and so on. Meanwhile, we‘ll keep track of an iteration variable because that tells us where we are in the scrub. We’ll also make sure that we only update iteration when we hit the progress thresholds.

Let’s start with an iteration variable:

let iteration = 0

Now let’s update our ScrollTrigger implementation:

const TRIGGER = ScrollTrigger.create({   start: 0,   end: '+=2000',   horizontal: false,   pin: '.boxes',   onUpdate: self => {     const SCROLL = self.scroll()     if (SCROLL > self.end - 1) {       // Go forwards in time       WRAP(1, 1)     } else if (SCROLL < 1 && self.direction <; 0) {       // Go backwards in time       WRAP(-1, self.end - 1)     } else {       SCRUB.vars.position = (iteration + self.progress) * LOOP_HEAD.duration()       SCRUB.invalidate().restart()      }   } }) 

Notice how we‘re now factoring iteration into the position calculation. Remember that this gets wrapped with the scrubber. We‘re also detecting when we hit the limits of our scroll, and that’s the point where we WRAP. This function sets the appropriate iteration value and sets the new scroll position.

const WRAP = (iterationDelta, scrollTo) => {   iteration += iterationDelta   TRIGGER.scroll(scrollTo)   TRIGGER.update() }

We have infinite scrolling! If you have one of those fancy mice with the scroll wheel that you can let loose on, give it a go! It’s fun!

Here’s a demo that displays the current iteration and progress:

Scroll snapping

We‘re there. But, there are always ”nice to haves” when working on a feature like this. Let’s start with scroll snapping. GSAP makes this easy, as we can use gsap.utils.snap without any other dependencies. That handles snapping to a time when we provide the points. We declare the step between 0 and 1 and we have 10 boxes in our example. That means a snap of 0.1 would work for us.

const SNAP = gsap.utils.snap(1 / BOXES.length)

And that returns a function we can use to snap our position value.

We only want to snap once the scroll has ended. For that, we can use an event listener on ScrollTrigger. When the scroll ends, we are going to scroll to a certain position.

ScrollTrigger.addEventListener('scrollEnd', () => {   scrollToPosition(SCRUB.vars.position) })

And here’s scrollToPosition:

const scrollToPosition = position => {   const SNAP_POS = SNAP(position)   const PROGRESS =     (SNAP_POS - LOOP_HEAD.duration() * iteration) / LOOP_HEAD.duration()   const SCROLL = progressToScroll(PROGRESS)   TRIGGER.scroll(SCROLL) }

What are we doing here?

  1. Calculating the point in time to snap to
  2. Calculating the current progress. Let’s say the LOOP_HEAD.duration() is 1 and we’ve snapped to 2.5. That gives us a progress of 0.5 resulting in an iteration of 2, where 2.5 - 1 * 2 / 1 === 0.5 . We calculate the progress so that it’s always between 1 and 0.
  3. Calculating the scroll destination. This is a fraction of the distance our ScrollTrigger can cover. In our example, we’ve set a distance of 2000 and we want a fraction of that. We create a new function progressToScroll to calculate it.
const progressToScroll = progress =>   gsap.utils.clamp(1, TRIGGER.end - 1, gsap.utils.wrap(0, 1, progress) * TRIGGER.end)

This function takes the progress value and maps it to the largest scroll distance. But we use a clamp to make sure the value can never be 0 or 2000. This is important. We are safeguarding against snapping to these values as it would put us in an infinite loop.

There is a bit to take in there. Check out this demo that shows the updated values on each snap.

Why are things a lot snappier? The scrubbing duration and ease have been altered. A smaller duration and punchier ease give us the snap.

const SCRUB = gsap.to(PLAYHEAD, {   position: 0,   onUpdate: () => {     LOOP_HEAD.totalTime(POSITION_WRAP(PLAYHEAD.position))   },   paused: true,   duration: 0.25,   ease: 'power3', })

But, if you played with that demo, you‘ll notice there‘s an issue. Sometimes when we wrap around inside the snap, the playhead jumps about. We need to account for that by making sure we wrap when we snap — but, only when it’s necessary.

const scrollToPosition = position => {   const SNAP_POS = SNAP(position)   const PROGRESS =     (SNAP_POS - LOOP_HEAD.duration() * iteration) / LOOP_HEAD.duration()   const SCROLL = progressToScroll(PROGRESS)   if (PROGRESS >= 1 || PROGRESS < 0) return WRAP(Math.floor(PROGRESS), SCROLL)   TRIGGER.scroll(SCROLL) }

And now we have infinite scrolling with snapping!

What next?

We’ve completed the groundwork for a solid infinite scroller. We can leverage that to add things, like controls or keyboard functionality. For example, this could be a way to hook up “Next” and “Previous” buttons and keyboard controls. All we have to do is manipulate time, right?

const NEXT = () => scrollToPosition(SCRUB.vars.position - (1 / BOXES.length)) const PREV = () => scrollToPosition(SCRUB.vars.position + (1 / BOXES.length))  // Left and Right arrow plus A and D document.addEventListener('keydown', event => {   if (event.keyCode === 37 || event.keyCode === 65) NEXT()   if (event.keyCode === 39 || event.keyCode === 68) PREV() })  document.querySelector('.next').addEventListener('click', NEXT) document.querySelector('.prev').addEventListener('click', PREV)

That could give us something like this.

We can leverage our scrollToPosition function and bump the value as we need.

That’s it!

See that? GSAP can animate more than elements! Here, we bent and manipulated time to create an almost perfect infinite slider. No duplicate elements, no mess, and good flexibility.

Let’s recap what we covered:

  • We can animate an animation. 🤯
  • We can think about timing as a positioning tools when we manipulate time.
  • How to use ScrollTrigger to scrub an animation via proxy.
  • How to use some of GSAP’s awesome utilities to handle logic for us.

You can now manipulate time! 😅

That concept of going “meta” GSAP opens up a variety of possibilities. What else could you animate? Audio? Video? As for the ”Cover Flow” demo, here’s where that went!


The post Going “Meta GSAP”: The Quest for “Perfect” Infinite Scrolling appeared first on CSS-Tricks.

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

CSS-Tricks

, , , , , ,
[Top]

Nailing the Perfect Contrast Between Light Text and a Background Image

Have you ever come across a site where light text is sitting on a light background image? If you have, you’ll know how difficult that is to read. A popular way to avoid that is to use a transparent overlay. But this leads to an important question: Just how transparent should that overlay be? It’s not like we’re always dealing with the same font sizes, weights, and colors, and, of course, different images will result in different contrasts.

Trying to stamp out poor text contrast on background images is a lot like playing Whac-a-Mole. Instead of guessing, we can solve this problem with HTML <canvas> and a little bit of math.

Like this:

We could say “Problem solved!” and simply end this article here. But where’s the fun in that? What I want to show you is how this tool works so you have a new way to handle this all-too-common problem.

Here’s the plan

First, let’s get specific about our goals. We’ve said we want readable text on top of a background image, but what does “readable” even mean? For our purposes, we’ll use the WCAG definition of AA-level readability, which says text and background colors need enough contrast between them such that that one color is 4.5 times lighter than the other.

Let’s pick a text color, a background image, and an overlay color as a starting point. Given those inputs, we want to find the overlay opacity level that makes the text readable without hiding the image so much that it, too, is difficult to see. To complicate things a bit, we’ll use an image with both dark and light space and make sure the overlay takes that into account.

Our final result will be a value we can apply to the CSS opacity property of the overlay that gives us the right amount of transparency that makes the text 4.5 times lighter than the background.

Optimal overlay opacity: 0.521

To find the optimal overlay opacity we’ll go through four steps:

  1. We’ll put the image in an HTML <canvas>, which will let us read the colors of each pixel in the image.
  2. We’ll find the pixel in the image that has the least contrast with the text.
  3. Next, we’ll prepare a color-mixing formula we can use to test different opacity levels on top of that pixel’s color.
  4. Finally, we’ll adjust the opacity of our overlay until the text contrast hits the readability goal. And these won’t just be random guesses — we’ll use binary search techniques to make this process quick.

Let’s get started!

Step 1: Read image colors from the canvas

Canvas lets us “read” the colors contained in an image. To do that, we need to “draw” the image onto a <canvas> element and then use the canvas context (ctx) getImageData() method to produce a list of the image’s colors.

function getImagePixelColorsUsingCanvas(image, canvas) {   // The canvas's context (often abbreviated as ctx) is an object   // that contains a bunch of functions to control your canvas   const ctx = canvas.getContext('2d'); 
   // The width can be anything, so I picked 500 because it's large   // enough to catch details but small enough to keep the   // calculations quick.   canvas.width = 500; 
   // Make sure the canvas matches proportions of our image   canvas.height = (image.height / image.width) * canvas.width; 
   // Grab the image and canvas measurements so we can use them in the next step   const sourceImageCoordinates = [0, 0, image.width, image.height];   const destinationCanvasCoordinates = [0, 0, canvas.width, canvas.height]; 
   // Canvas's drawImage() works by mapping our image's measurements onto   // the canvas where we want to draw it   ctx.drawImage(     image,     ...sourceImageCoordinates,     ...destinationCanvasCoordinates   ); 
   // Remember that getImageData only works for same-origin or    // cross-origin-enabled images.   // https://developer.mozilla.org/en-US/docs/Web/HTML/CORS_enabled_image   const imagePixelColors = ctx.getImageData(...destinationCanvasCoordinates);   return imagePixelColors; }

The getImageData() method gives us a list of numbers representing the colors in each pixel. Each pixel is represented by four numbers: red, green, blue, and opacity (also called “alpha”). Knowing this, we can loop through the list of pixels and find whatever info we need. This will be useful in the next step.

Image of a blue and purple rose on a light pink background. A section of the rose is magnified to reveal the RGBA values of a specific pixel.

Step 2: Find the pixel with the least contrast

Before we do this, we need to know how to calculate contrast. We’ll write a function called getContrast() that takes in two colors and spits out a number representing the level of contrast between the two. The higher the number, the better the contrast for legibility.

When I started researching colors for this project, I was expecting to find a simple formula. It turned out there were multiple steps.

To calculate the contrast between two colors, we need to know their luminance levels, which is essentially the brightness (Stacie Arellano does a deep dive on luminance that’s worth checking out.)

Thanks to the W3C, we know the formula for calculating contrast using luminance:

const contrast = (lighterColorLuminance + 0.05) / (darkerColorLuminance + 0.05);

Getting the luminance of a color means we have to convert the color from the regular 8-bit RGB value used on the web (where each color is 0-255) to what’s called linear RGB. The reason we need to do this is that brightness doesn’t increase evenly as colors change. We need to convert our colors into a format where the brightness does vary evenly with color changes. That allows us to properly calculate luminance. Again, the W3C is a help here:

const luminance = (0.2126 * getLinearRGB(r) + 0.7152 * getLinearRGB(g) + 0.0722 * getLinearRGB(b));

But wait, there’s more! In order to convert 8-bit RGB (0 to 255) to linear RGB, we need to go through what’s called standard RGB (also called sRGB), which is on a scale from 0 to 1.

So the process goes: 

8-bit RGB → standard RGB  → linear RGB → luminance

And once we have the luminance of both colors we want to compare, we can plug in the luminance values to get the contrast between their respective colors.

// getContrast is the only function we need to interact with directly. // The rest of the functions are intermediate helper steps. function getContrast(color1, color2) {   const color1_luminance = getLuminance(color1);   const color2_luminance = getLuminance(color2);   const lighterColorLuminance = Math.max(color1_luminance, color2_luminance);   const darkerColorLuminance = Math.min(color1_luminance, color2_luminance);   const contrast = (lighterColorLuminance + 0.05) / (darkerColorLuminance + 0.05);   return contrast; } 
 function getLuminance({r,g,b}) {   return (0.2126 * getLinearRGB(r) + 0.7152 * getLinearRGB(g) + 0.0722 * getLinearRGB(b)); } function getLinearRGB(primaryColor_8bit) {   // First convert from 8-bit rbg (0-255) to standard RGB (0-1)   const primaryColor_sRGB = convert_8bit_RGB_to_standard_RGB(primaryColor_8bit); 
   // Then convert from sRGB to linear RGB so we can use it to calculate luminance   const primaryColor_RGB_linear = convert_standard_RGB_to_linear_RGB(primaryColor_sRGB);   return primaryColor_RGB_linear; } function convert_8bit_RGB_to_standard_RGB(primaryColor_8bit) {   return primaryColor_8bit / 255; } function convert_standard_RGB_to_linear_RGB(primaryColor_sRGB) {   const primaryColor_linear = primaryColor_sRGB < 0.03928 ?     primaryColor_sRGB/12.92 :     Math.pow((primaryColor_sRGB + 0.055) / 1.055, 2.4);   return primaryColor_linear; }

Now that we can calculate contrast, we’ll need to look at our image from the previous step and loop through each pixel, comparing the contrast between that pixel’s color and the foreground text color. As we loop through the image’s pixels, we’ll keep track of the worst (lowest) contrast so far, and when we reach the end of the loop, we’ll know the worst-contrast color in the image.

function getWorstContrastColorInImage(textColor, imagePixelColors) {   let worstContrastColorInImage;   let worstContrast = Infinity; // This guarantees we won't start too low   for (let i = 0; i < imagePixelColors.data.length; i += 4) {     let pixelColor = {       r: imagePixelColors.data[i],       g: imagePixelColors.data[i + 1],       b: imagePixelColors.data[i + 2],     };     let contrast = getContrast(textColor, pixelColor);     if(contrast < worstContrast) {       worstContrast = contrast;       worstContrastColorInImage = pixelColor;     }   }   return worstContrastColorInImage; }

Step 3: Prepare a color-mixing formula to test overlay opacity levels

Now that we know the worst-contrast color in our image, the next step is to establish how transparent the overlay should be and see how that changes the contrast with the text.

When I first implemented this, I used a separate canvas to mix colors and read the results. However, thanks to Ana Tudor’s article about transparency, I now know there’s a convenient formula to calculate the resulting color from mixing a base color with a transparent overlay.

For each color channel (red, green, and blue), we’d apply this formula to get the mixed color:

mixedColor = baseColor + (overlayColor - baseColor) * overlayOpacity

So, in code, that would look like this:

function mixColors(baseColor, overlayColor, overlayOpacity) {   const mixedColor = {     r: baseColor.r + (overlayColor.r - baseColor.r) * overlayOpacity,     g: baseColor.g + (overlayColor.g - baseColor.g) * overlayOpacity,     b: baseColor.b + (overlayColor.b - baseColor.b) * overlayOpacity,   }   return mixedColor; }

Now that we’re able to mix colors, we can test the contrast when the overlay opacity value is applied.

function getTextContrastWithImagePlusOverlay({textColor, overlayColor, imagePixelColor, overlayOpacity}) {   const colorOfImagePixelPlusOverlay = mixColors(imagePixelColor, overlayColor, overlayOpacity);   const contrast = getContrast(this.state.textColor, colorOfImagePixelPlusOverlay);   return contrast; }

With that, we have all the tools we need to find the optimal overlay opacity!

Step 4: Find the overlay opacity that hits our contrast goal

We can test an overlay’s opacity and see how that affects the contrast between the text and image. We’re going to try a bunch of different opacity levels until we find the contrast that hits our mark where the text is 4.5 times lighter than the background. That may sound crazy, but don’t worry; we’re not going to guess randomly. We’ll use a binary search, which is a process that lets us quickly narrow down the possible set of answers until we get a precise result.

Here’s how a binary search works:

  • Guess in the middle.
  • If the guess is too high, we eliminate the top half of the answers. Too low? We eliminate the bottom half instead.
  • Guess in the middle of that new range.
  • Repeat this process until we get a value.

I just so happen to have a tool to show how this works:

In this case, we’re trying to guess an opacity value that’s between 0 and 1. So, we’ll guess in the middle, test whether the resulting contrast is too high or too low, eliminate half the options, and guess again. If we limit the binary search to eight guesses, we’ll get a precise answer in a snap.

Before we start searching, we’ll need a way to check if an overlay is even necessary in the first place. There’s no point optimizing an overlay we don’t even need!

function isOverlayNecessary(textColor, worstContrastColorInImage, desiredContrast) {   const contrastWithoutOverlay = getContrast(textColor, worstContrastColorInImage);   return contrastWithoutOverlay < desiredContrast; }

Now we can use our binary search to look for the optimal overlay opacity:

function findOptimalOverlayOpacity(textColor, overlayColor, worstContrastColorInImage, desiredContrast) {   // If the contrast is already fine, we don't need the overlay,   // so we can skip the rest.   const isOverlayNecessary = isOverlayNecessary(textColor, worstContrastColorInImage, desiredContrast);   if (!isOverlayNecessary) {     return 0;   } 
   const opacityGuessRange = {     lowerBound: 0,     midpoint: 0.5,     upperBound: 1,   };   let numberOfGuesses = 0;   const maxGuesses = 8; 
   // If there's no solution, the opacity guesses will approach 1,   // so we can hold onto this as an upper limit to check for the no-solution case.   const opacityLimit = 0.99; 
   // This loop repeatedly narrows down our guesses until we get a result   while (numberOfGuesses < maxGuesses) {     numberOfGuesses++; 
     const currentGuess = opacityGuessRange.midpoint;     const contrastOfGuess = getTextContrastWithImagePlusOverlay({       textColor,       overlayColor,       imagePixelColor: worstContrastColorInImage,       overlayOpacity: currentGuess,     }); 
     const isGuessTooLow = contrastOfGuess < desiredContrast;     const isGuessTooHigh = contrastOfGuess > desiredContrast;     if (isGuessTooLow) {       opacityGuessRange.lowerBound = currentGuess;     }     else if (isGuessTooHigh) {       opacityGuessRange.upperBound = currentGuess;     } 
     const newMidpoint = ((opacityGuessRange.upperBound - opacityGuessRange.lowerBound) / 2) + opacityGuessRange.lowerBound;     opacityGuessRange.midpoint = newMidpoint;   } 
   const optimalOpacity = opacityGuessRange.midpoint;   const hasNoSolution = optimalOpacity > opacityLimit; 
   if (hasNoSolution) {     console.log('No solution'); // Handle the no-solution case however you'd like     return opacityLimit;   }   return optimalOpacity; }

With our experiment complete, we now know exactly how transparent our overlay needs to be to keep our text readable without hiding the background image too much.

We did it!

Improvements and limitations

The methods we’ve covered only work if the text color and the overlay color have enough contrast to begin with. For example, if you were to choose a text color that’s the same as your overlay, there won’t be an optimal solution unless the image doesn’t need an overlay at all.

In addition, even if the contrast is mathematically acceptable, that doesn’t always guarantee it’ll look great. This is especially true for dark text with a light overlay and a busy background image. Various parts of the image may distract from the text, making it difficult to read even when the contrast is numerically fine. That’s why the popular recommendation is to use light text on a dark background.

We also haven’t taken where the pixels are located into account or how many there are of each color. One drawback of that is that a pixel in the corner could possibly exert too much influence on the result. The benefit, however, is that we don’t have to worry about how the image’s colors are distributed or where the text is because, as long as we’ve handled where the least amount of contrast is, we’re safe everywhere else.

I learned a few things along the way

There are some things I walked away with after this experiment, and I’d like to share them with you:

  • Getting specific about a goal really helps! We started with a vague goal of wanting readable text on an image, and we ended up with a specific contrast level we could strive for.
  • It’s so important to be clear about the terms. For example, standard RGB wasn’t what I expected. I learned that what I thought of as “regular” RGB (0 to 255) is formally called 8-bit RGB. Also, I thought the “L” in the equations I researched meant “lightness,” but it actually means “luminance,” which is not to be confused with “luminosity.” Clearing up terms helps how we code as well as how we discuss the end result.
  • Complex doesn’t mean unsolvable. Problems that sound hard can be broken into smaller, more manageable pieces.
  • When you walk the path, you spot the shortcuts. For the common case of white text on a black transparent overlay, you’ll never need an opacity over 0.54 to achieve WCAG AA-level readability.

In summary…

You now have a way to make your text readable on a background image without sacrificing too much of the image. If you’ve gotten this far, I hope I’ve been able to give you a general idea of how it all works.

I originally started this project because I saw (and made) too many website banners where the text was tough to read against a background image or the background image was overly obscured by the overlay. I wanted to do something about it, and I wanted to give others a way to do the same. I wrote this article in hopes that you’d come away with a better understanding of readability on the web. I hope you’ve learned some neat canvas tricks too.

If you’ve done something interesting with readability or canvas, I’d love to hear about it in the comments!


The post Nailing the Perfect Contrast Between Light Text and a Background Image appeared first on CSS-Tricks.

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

CSS-Tricks

, , , , , , ,
[Top]

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]

Perfect Image Optimization for Mobile with Optimole

(This is a sponsored post.)

In 2015 there were 24,000 different Android devices, and each of them was capable of downloading images. And this was just the beginning. The mobile era is starting to gather pace with mobile visitors starting to eclipse desktop. One thing is certain, building and running a website in the mobile era requires an entirely different approach to images. You need to consider users on desktops, laptops, tablets, watches, smartphones and then viewport sizes, resolutions, before, finally, getting to browser supported formats.

So, what’s a good image optimization option to help you juggle all of these demands without sacrificing image quality and on-page SEO strategy?

Say hello to Optimole.

If you’ve ever wondered how many breakpoints a folding screen will have, then you’ll probably like Optimole. It integrates with both the WordPress block editor and page builders to solve a bunch of image optimization problems. It’s an all-in-one service, so you can optimize images and also take advantage of features built to help you improve your page speed.

What’s different about Optimole?

The next step in image optimization is device specificity, so the traditional method of catch and replace image optimization will no longer be the best option for your website. Most image optimizers work in the same way:

  1. Select images for optimization.
  2. Replace images on the page.
  3. Delete the original.

They provide multiple static images for each screen size according to the breakpoints defined in your code. Essentially, you are providing multiple images to try and Optimized? Sure. Optimal? Hardly.

Optimole works like this:

  1. Your images get sucked into the cloud and optimized.
  2. Optimole replaces the standard image URLs with new ones – tailored to the user’s screen.
  3. The images go through a fast CDN to make them load lightning fast.

Here’s why this is better: You will be serving perfectly sized images to every device, faster, in the best format, and without an unnecessary load on your servers.

A quick case study: CodeinWP

Optimole’s first real test was on CodeinWP as part of a full site redesign. The blog has been around for a while during which time is has emerged as one of the leaders in the WordPress space. Performance wise? Not so much. With over 150,000 monthly visitors, the site needed to provide a better experience for Google and mobile.

Optimole was used as one part of a mobile first strategy. In the early stages, Optimole helped provide a ~0.4s boost in load time for most pages with it enabled. Whereas, on desktop, Optimole is compressing images by ~50% and improving pagespeed grades by 23%. Fully loaded time and the total page size is reduced by ~19%.

Improved PageSpeed and quicker delivery

Along with a device-specific approach, Optimole provides an image by image optimization to ensure each image fits perfectly into the targeted container. Google will love it. These savings in bandwidth are going to help you improve your PageSpeed scores.

It’s not always about the numbers; your website needs to conform to expected behavior even when rendering on mobile. You can avoid content jumping and shifting with any number of tweaks but a container based lazy loading option provides the best user experience. Optimole sends a blurred image at the exact container size, so your visitors never lose their place on the page.

We offer CDNs for both free and premium users. If you’re already using CDN, then we can integrate it from our end. The extra costs involved will be balanced out with a monthly discount.

Picking the perfect image for every device

Everyone loves <srcsets> and <sizes> but it is time for an automated solution that doesn’t leak bandwidth. With client hints, Optimole provides dynamic image resizing that provides a perfectly sized image for each and every device.

Acting as a proxy service allows Optimole to deliver unique images based on supported formats. Rather than replace an image on the page with a broad appeal, Optimole provides the best image based on the information provided by the browser. This dynamism means WebP and Retina displays are supported for, lucky, users without needing to make any changes.

Optimole can be set to detect slower connections, and deliver an image with a high compression rate to keep the page load time low.

Industrial strength optimization with a simple UI

The clean and simple UI gives you the options you need to make a site; no muss no fuss. You can set the parameters, introduce lazy loading, and test the quality without touching up any of the URLs.

You can reduce the extra CSS you need to make page builder images responsive and compressed. It currently takes time and a few CSS tricks to get all of your Elementor images responsive. For example, the extra thumbnails from galleries and carousels can throw a few curve balls. With Optimole all of these images are picked up from the page, and treated like any other. Automatically.

One of the reasons to avoid changing image optimization strategies is the, frightening, prospect of going through the optimization process again. Optimole is the set-and-forget optimization option that optimizes your images without making changes to the original image. Just install, activate and let Optimole handle the rest.

Set. Forget. Work on important things.

Try it today

Get some insight into how Optimole can help your site with the speed test here.

If you like what you see then you can get a fully functional free plan with all the features. It includes 1GB of image optimization and 5GB of viewing bandwidth. The premium plans start with 10GB of image optimization, and 50GB of viewing bandwidth, plus access to an enhanced CDN including over 130 locations.

If you’re worried about making a big change, then rest assured Optimole can be uninstalled cleanly to leave your site exactly as before.

Direct Link to ArticlePermalink

The post Perfect Image Optimization for Mobile with Optimole appeared first on CSS-Tricks.

CSS-Tricks

, , , ,
[Top]