This is the solution post for Challenge #003 and it’s a fun one!
This solution has the following code files:
index.html
, referred to as “your markup” for the rest of this tutorialcss/global.css
, referred to as “your CSS” for the rest of this tutorial
You can see a live demo of the final solution here.
HTMLpermalink
Let’s first add the base HTML structure to your empty index.html file:
- Code language
- html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta http-equiv="X-UA-Compatible" content="ie=edge" /> <title>Front-End Challenges Club - 003</title> <link rel="stylesheet" href="https://unpkg.com/modern-css-reset/dist/reset.min.css" /> <link rel="stylesheet" href="/css/global.css" /> </head> <body> <main class="wrapper"></main> </body> </html>
This is our core structure and as you can see, it’s pretty simple. We’re pulling in our CSS, along with a CDN version of my modern reset that I use.
With this now in place: inside the <main>
element, add the following markup:
- Code language
- html
<h1>Meet the team</h1> <div class="switcher"></div>
This is just our outer layout structure. Now we need to fill it with three <article>
elements that are all exactly the same, apart from the content, so for this solution we’ll cover just one. All you will need to do at the end is duplicate that twice and add different content and images.
Add this content inside the <div class="switcher">
element:
- Code language
- html
<article class="duotone-card"> <div class="duotone-card__inner"> <div class="duotone-card__content"> <h2 class="duotone-card__heading">María Godoy</h2> <p class="duotone-card__summary">Founder</p> <svg version="1.1" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="duotone-card__icon" > <path d="M9.707 18.707l6-6c0.391-0.391 0.391-1.024 0-1.414l-6-6c-0.391-0.391-1.024-0.391-1.414 0s-0.391 1.024 0 1.414l5.293 5.293-5.293 5.293c-0.391 0.391-0.391 1.024 0 1.414s1.024 0.391 1.414 0z" ></path> </svg> </div> <a href="#" class="cover-button"> <span class="visually-hidden">View María’s profile</span> </a> <div class="duotone-card__media"> <img src="/images/image-1.jpg" width="300" alt="" aria-hidden="true" loading="lazy" /> </div> </div> </article>
This setup is pretty straightforward. We use an <article>
because semantically, the context of this element works well in that setup. Using a <h2>
allows screen readers to navigate by headings, which is super handy. You could of course use a list, too if you wanted. In the grand scheme of things, the choice of a collection of <article>
elements and <li>
elements isn’t that important.
One component to draw attention to is the .cover-button
component. This works very much like a breakout button which allows the whole parent element—which in this case is the <article>
—to be clicked/tapped. It allows us to create a nice semantic structure and provide a nice user experience. A win-win in my books.
That’s it for the HTML. Including the other two card elements, your HTML should now look like this:
- Code language
- html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta http-equiv="X-UA-Compatible" content="ie=edge" /> <title>Front-End Challenges Club - 003</title> <link rel="stylesheet" href="https://unpkg.com/modern-css-reset/dist/reset.min.css" /> <link rel="stylesheet" href="/css/global.css" /> </head> <body> <main class="wrapper"> <h1>Meet the team</h1> <div class="switcher"> <article class="duotone-card"> <div class="duotone-card__inner"> <div class="duotone-card__content"> <h2 class="duotone-card__heading">María Godoy</h2> <p class="duotone-card__summary">Founder</p> <svg version="1.1" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="duotone-card__icon" > <path d="M9.707 18.707l6-6c0.391-0.391 0.391-1.024 0-1.414l-6-6c-0.391-0.391-1.024-0.391-1.414 0s-0.391 1.024 0 1.414l5.293 5.293-5.293 5.293c-0.391 0.391-0.391 1.024 0 1.414s1.024 0.391 1.414 0z" ></path> </svg> </div> <a href="#" class="cover-button"> <span class="visually-hidden">View María’s profile</span> </a> <div class="duotone-card__media"> <img src="/images/image-1.jpg" width="300" alt="" aria-hidden="true" loading="lazy" /> </div> </div> </article> <article class="duotone-card"> <div class="duotone-card__inner"> <div class="duotone-card__content"> <h2 class="duotone-card__heading">Tiago Monteiro</h2> <p class="duotone-card__summary"> <abbr title="Chief Creative Officer">CCO</abbr> </p> <svg version="1.1" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="duotone-card__icon" > <path d="M9.707 18.707l6-6c0.391-0.391 0.391-1.024 0-1.414l-6-6c-0.391-0.391-1.024-0.391-1.414 0s-0.391 1.024 0 1.414l5.293 5.293-5.293 5.293c-0.391 0.391-0.391 1.024 0 1.414s1.024 0.391 1.414 0z" ></path> </svg> </div> <a href="#" class="cover-button"> <span class="visually-hidden">View Tiago’s profile</span> </a> <div class="duotone-card__media"> <img src="/images/image-3.jpg" width="300" alt="" aria-hidden="true" loading="lazy" /> </div> </div> </article> <article class="duotone-card"> <div class="duotone-card__inner"> <div class="duotone-card__content"> <h2 class="duotone-card__heading">Amelia Harris</h2> <p class="duotone-card__summary"> <abbr title="Chief Technical Officer">CTO</abbr> </p> <svg version="1.1" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="1em" height="1em" viewBox="0 0 24 24" class="duotone-card__icon" > <path d="M9.707 18.707l6-6c0.391-0.391 0.391-1.024 0-1.414l-6-6c-0.391-0.391-1.024-0.391-1.414 0s-0.391 1.024 0 1.414l5.293 5.293-5.293 5.293c-0.391 0.391-0.391 1.024 0 1.414s1.024 0.391 1.414 0z" ></path> </svg> </div> <a href="#" class="cover-button"> <span class="visually-hidden">View Amelia’s profile</span> </a> <div class="duotone-card__media"> <img src="/images/image-2.jpg" width="300" alt="" aria-hidden="true" loading="lazy" /> </div> </div> </article> </div> </main> </body> </html>
CSSpermalink
Now let’s get stuck into the real fun stuff. Let’s start by adding some globals. Add the following to your CSS:
- Code language
- css
/** * VARIABLES */ :root { --color-pink: #faa7ed; --color-navy: #0c1f72; --color-navy-opaque: rgba(6, 0, 79, 0.77); --color-light: #ffffff; --color-dark: #252525; --font-serif: Georgia, serif; --font-sans: Helvetica, sans-serif; --metric-wrapper: 40rem; --metric-gutter: 1.25rem; --transition-snappy: 200ms linear; --transition-silky: 300ms cubic-bezier(1, 0, 0.55, 0.85); } /** * GLOBAL STYLES */ body { padding: 5rem 1.5rem; font-family: var(--font-serif); background: var(--color-light); color: var(--color-dark); min-width: 18rem; } h1, h2 { font-weight: 400; line-height: 1.1; } h1 { font-size: 2rem; margin-bottom: 1rem; } h2 { font-size: 1rem; } abbr { text-decoration: none; }
We’ve set some :root
Custom Properties to control our colours and some metrics. We also control transition duration and timing function with Custom Properties.
For the global styles, we’re keeping it simple. It’s mainly <body>
styles and some heading work. The last bit is removing the weird underline on <abbr>
elements.
Let’s add some utilities. First up, we’re going to add our visually-hidden
utility that hides text visually, but allows assertive technology such as screen readers to access it:
- Code language
- css
/** * VISUALLY HIDDEN UTILITY */ .visually-hidden { border: 0; clip: rect(0 0 0 0); height: auto; margin: 0; overflow: hidden; padding: 0; position: absolute; width: 1px; white-space: nowrap; }
Next, we’ll add a wrapper
utility that creates a nice max-width container that sits horizontally in the center. You may have noticed that’s attached to our <main>
element.
Add this to your CSS:
- Code language
- css
/** * WRAPPER UTILITY */ .wrapper { max-width: var(--metric-wrapper); margin-left: auto; margin-right: auto; }
Now that we’ve added our utilities, let’s get stuck into a component. Add this to your CSS:
- Code language
- css
/** * SWITCHER COMPONENT */ .switcher { display: flex; flex-wrap: wrap; margin: calc(var(--metric-gutter) * -1) 0 0 calc(var(--metric-gutter) * -1); } .switcher > * { flex-basis: calc((var(--metric-wrapper) - 100%) * 999); flex-grow: 1; margin: var(--metric-gutter) 0 0 var(--metric-gutter); }
This one is a killer and it comes straight from Every Layout, which I co-authored. You can read about it in detail here.
In summary though, the .switcher
allows us to lay out a grid of items with no media queries. We set a “breakpoint” which essentially acts as a switch, because when flex-basis
is under 0
, it disables itself which in turn allows the element to stretch to fill space. It’s a super powerful CSS algorithm.
Next up, let’s get stuck into the main course and build our .duotone-card
component. Add this to your CSS:
- Code language
- css
/** * DUOTONE CARD COMPONENT */ .duotone-card { --duotone-card-media-opacity: 1; --duotone-card-media-brightness: 1.1; --duotone-card-media-grayscale: 1; --duotone-card-icon-x: -0.5rem; --duotone-card-icon-opacity: 0; --duotone-card-content-background: var(--color-navy-opaque); max-width: 15rem; } .duotone-card__inner { position: relative; padding-bottom: 115%; }
We start off by creating a whole bunch of Custom Properties that are scoped to this element. I still add the component name as a prefix in this situation because I believe it helps with readability.
The role of these Custom Properties in twofold. Firstly, they are used to style inner elements consistently, but most importantly, they are used to make the hover and focus states much cleaner. This will be apparent when we get to that section.
We set a max-width
so that when the .switcher
is stacked, the elements don’t get massive.
Lastly, on the .duotone-card__inner
element, we set a padding-bottom
value of 115%
. This is because we want our height to be more than our width, consistently, without setting a height. This also creates a solid shape for us to have absolute
child elements, which this component has plenty of!Handy trick, right? You can read more about how this works here.
Add the following to your CSS:
- Code language
- css
.duotone-card__content { width: 100%; position: absolute; bottom: 0; left: 0; z-index: 2; background: var(--duotone-card-content-background); color: var(--color-light); padding: 0.6rem 3rem 0.6rem 0.6rem; line-height: 1.1; } .duotone-card__summary { font-family: var(--font-sans); font-size: 0.88rem; margin-top: 0.3rem; } .duotone-card__icon { fill: currentcolor; position: absolute; right: 0.5rem; top: 50%; transform: translate(var(--duotone-card-icon-x), -50%); opacity: var(--duotone-card-icon-opacity); font-size: 1.5em; transition: opacity var(--transition-snappy), transform var(--transition-silky); }
Here, we style that area where our team member’s name and title appear. Notice how we’re using the scoped Custom Property because this changes on hover. You could push this to the bottom with flexbox
but I’ve found using absolute positioning in this context to be the simplest, most pragmatic approach.
For the icon, I use font-size
to style it because it set its width and height as 1em
inline. It’s a great way of scaling icons in their context. You can read more about that here. By default the icon is visually hidden thanks to the scoped Custom Property that reveals it on hover.
The rest of this is pretty self-explanatory, but I’ll cover the z-index
part. I set that to 2
so it sits on top of everything in this component. It’s already in a new stacking context, so this is a pretty straightforward z-index
override.
Next up add this to your CSS:
- Code language
- css
.duotone-card__media { position: absolute; width: 100%; height: 100%; } .duotone-card__media img { width: 100%; height: 100%; object-fit: cover; filter: grayscale(var(--duotone-card-media-grayscale)) brightness( var(--duotone-card-media-brightness) ); transition: filter var(--transition-snappy); }
Because we set our aspect ratio earlier, it’s made working with our image much easier. We can just set it to fill the container and then use object-fit: cover
to fix any image skewing that comes from setting both width and height.
We use the scoped Custom Properties to set the filter
which controls the grayscale
and brightness
. For the default state, I want the image to be black and white with some extra brightness. This really enhances the duotone effect because if you leave colour in, the lowlights struggle to break through.
Speaking of duotone: let’s add it! Add this to your CSS:
- Code language
- css
@supports (mix-blend-mode: multiply) { .duotone-card__media::before, .duotone-card__media::after { content: ''; display: block; width: 100%; height: 100%; position: absolute; top: 0; left: 0; z-index: 1; opacity: var(--duotone-card-media-opacity); transition: opacity var(--transition-snappy); } .duotone-card__media::before { background: var(--color-pink); mix-blend-mode: multiply; } .duotone-card__media::after { background: var(--color-navy); mix-blend-mode: lighten; } }
We set our ::before
and ::after
pseudo-elements of .duotone-card__media
to be absolutely positioned and fill the container that they live in. We also set the z-index
of them to sit between the image and the content. We also use the scoped Custom Properties to control opacity.
We use mix-blend-mode
to create our duotone effect:
- For the bottom layer,
::before
, we usemultiply
because we want our pink colour to replace the highlights of our image. - For the top layer,
::after
, we uselighten
because we want our navy colour to be replaced with colours that our lighter than it. This means the navy only shows up for lowlights
We fence all of this in an @supports
block which tests support for us and prevents two solid coloured blocks where there is no support for mix-blend-mode
. This also means browsers that don’t support mix-blend-mode
will get black and white images. Good ol’ progressive enhancement.
Now let’s add our hover and focus styles:
- Code language
- css
.duotone-card:hover, .duotone-card:focus-within { --duotone-card-media-opacity: 0; --duotone-card-media-brightness: 1; --duotone-card-media-grayscale: 0; --duotone-card-icon-opacity: 1; --duotone-card-icon-x: 0; --duotone-card-content-background: var(--color-navy); }
“That’s it?” They scream.
Yep! That is it indeed. Because we used scoped Custom Properties which are affected by the cascade and specificity, we can just switch them on hover to do all of the heavy work for us. This prevents us writing complex selectors that are a nightmare to read. Consider this Andy’s tip of the day 😉
The last bit to cover here is :focus-within
. This is a handy rule that allows us to say “if there’s some focus inside the component, use this CSS”. It’s not fully supported yet in all browsers, but it’s very much a progressive enhancement because we’re adding a proper focus state next.
Right, we have one last bit of CSS to add: our .cover-button
. Add this to your CSS:
- Code language
- css
/** * COVER BUTTON COMPONENT */ .cover-button { display: block; width: 100%; height: 100%; position: absolute; z-index: 3; } .cover-button:focus { outline: 0.25rem solid var(--color-navy); }
This is mostly self explanatory. The key point is that z-index
is set to 3
so it sits at the very top. Remember, we hide the text of this element with visually-hidden
. The reason we don’t visually hide this with opacity is that we want our focus style to be visible.
For focus, I’ve gone with a thick dark border. This creates plenty of contrast against the background of these elements, so it’s a really effective focus state.
And with that, we are done!!
Your finished CSS should look like this:
- Code language
- css
/** * VARIABLES */ :root { --color-pink: #faa7ed; --color-navy: #0c1f72; --color-navy-opaque: rgba(6, 0, 79, 0.77); --color-light: #ffffff; --color-dark: #252525; --font-serif: Georgia, serif; --font-sans: Helvetica, sans-serif; --metric-wrapper: 40rem; --metric-gutter: 1.25rem; --transition-snappy: 200ms linear; --transition-silky: 300ms cubic-bezier(1, 0, 0.55, 0.85); } /** * GLOBAL STYLES */ body { padding: 5rem 1.5rem; font-family: var(--font-serif); background: var(--color-light); color: var(--color-dark); min-width: 18rem; } h1, h2 { font-weight: 400; line-height: 1.1; } h1 { font-size: 2rem; margin-bottom: 1rem; } h2 { font-size: 1rem; } abbr { text-decoration: none; } /** * VISUALLY HIDDEN UTILITY */ .visually-hidden { border: 0; clip: rect(0 0 0 0); height: auto; margin: 0; overflow: hidden; padding: 0; position: absolute; width: 1px; white-space: nowrap; } /** * WRAPPER UTILITY */ .wrapper { max-width: var(--metric-wrapper); margin-left: auto; margin-right: auto; } /** * SWITCHER COMPONENT */ .switcher { display: flex; flex-wrap: wrap; margin: calc(var(--metric-gutter) * -1) 0 0 calc(var(--metric-gutter) * -1); } .switcher > * { flex-basis: calc((var(--metric-wrapper) - 100%) * 999); flex-grow: 1; margin: var(--metric-gutter) 0 0 var(--metric-gutter); } /** * DUOTONE CARD COMPONENT */ .duotone-card { --duotone-card-media-opacity: 1; --duotone-card-media-brightness: 1.1; --duotone-card-media-grayscale: 1; --duotone-card-icon-x: -0.5rem; --duotone-card-icon-opacity: 0; --duotone-card-content-background: var(--color-navy-opaque); max-width: 15rem; } .duotone-card__inner { position: relative; padding-bottom: 115%; } .duotone-card__content { width: 100%; position: absolute; bottom: 0; left: 0; z-index: 2; background: var(--duotone-card-content-background); color: var(--color-light); padding: 0.6rem 3rem 0.6rem 0.6rem; line-height: 1.1; } .duotone-card__summary { font-family: var(--font-sans); font-size: 0.88rem; margin-top: 0.3rem; } .duotone-card__icon { fill: currentcolor; position: absolute; right: 0.5rem; top: 50%; transform: translate(var(--duotone-card-icon-x), -50%); opacity: var(--duotone-card-icon-opacity); font-size: 1.5em; transition: opacity var(--transition-snappy), transform var(--transition-silky); } .duotone-card__media { position: absolute; width: 100%; height: 100%; } .duotone-card__media img { width: 100%; height: 100%; object-fit: cover; filter: grayscale(var(--duotone-card-media-grayscale)) brightness( var(--duotone-card-media-brightness) ); transition: filter var(--transition-snappy); } @supports (mix-blend-mode: multiply) { .duotone-card__media::before, .duotone-card__media::after { content: ''; display: block; width: 100%; height: 100%; position: absolute; top: 0; left: 0; z-index: 1; opacity: var(--duotone-card-media-opacity); transition: opacity var(--transition-snappy); } .duotone-card__media::before { background: var(--color-pink); mix-blend-mode: multiply; } .duotone-card__media::after { background: var(--color-navy); mix-blend-mode: lighten; } } /* Switch the custom property values on hover instead of writing complex selectors */ .duotone-card:hover, .duotone-card:focus-within { --duotone-card-media-opacity: 0; --duotone-card-media-brightness: 1; --duotone-card-media-grayscale: 0; --duotone-card-icon-opacity: 1; --duotone-card-icon-x: 0; --duotone-card-content-background: var(--color-navy); } /** * COVER BUTTON COMPONENT */ .cover-button { display: block; width: 100%; height: 100%; position: absolute; z-index: 3; } .cover-button:focus { outline: 0.25rem solid var(--color-navy); }
Wrapping uppermalink
I really hope you’ve enjoyed this tutorial. You can grab a zip of the final code that I wrote and also see a live version too!
My favourite attempt at this was by Paddy Duke. They’ve gone to town on this and really pushed it beyond the initial brief by adding more items and adding some nice contextual design elements. You can see it here.
Until the next challenge, take it easy and as always, thank you for being a supporter of this project 🥰