<button class="app" type="button">
<div class="app__badge">
<div class="app__badge-count" role="status" aria-label="" aria-atomic="true" aria-live="polite" data-count></div>
<div class="app__badge-text" data-preview></div>
</div>
<svg class="app__icon" viewBox="0 0 512 512" width="32px" height="32px" aria-hidden="true">
<path fill="currentcolor" d="M256,0C114.6,0,0,85.9,0,192c0,75,57.5,139.8,141.1,171.4L85.3,512l160.5-128.4c3.4,0.1,6.7,0.4,10.2,0.4c141.4,0,256-85.9,256-192C512,85.9,397.4,0,256,0z"/>
<g fill="hsl(0,0%,100%)">
<circle class="app__icon-dot" r="32" cx="144" cy="192" />
<circle class="app__icon-dot" r="32" cx="256" cy="192" />
<circle class="app__icon-dot" r="32" cx="368" cy="192" />
</g>
</svg>
</button>
@use "sass:map";
$badgeExpandedWidth: 3.75em;
$timings: (
"ease-in-out": cubic-bezier(0.65,0,0.35,1),
"ease-in": cubic-bezier(0.33,0,0.67,0),
"ease-out": cubic-bezier(0.33,1,0.67,1),
);
* {
border: 0;
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--hue: 223;
--hue2: 133;
--hue3: 3;
--bg: hsl(var(--hue2),90%,70%);
--fg: hsl(var(--hue),90%,10%);
--primary: hsl(var(--hue),90%,50%);
--trans-dur: 0.3s;
--trans-timing: cubic-bezier(0.65,0,0.35,1);
font-size: calc(20px + (60 - 20) * (100vw - 280px) / (3840 - 280));
}
body,
button {
font: 1em/1.5 system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, sans-serif;
}
body {
background-color: var(--bg);
color: var(--fg);
display: flex;
height: 100vh;
transition:
background-color var(--trans-dur),
color var(--trans-dur);
}
.app {
--dot-dur: 1s;
background-color: hsl(0,0%,100%);
border-radius: 1em;
box-shadow:
0 0 0 0.333em hsla(0,0%,56%,0),
0 0.75em 1.5em hsla(var(--hue2),90%,30%,0.3);
cursor: pointer;
display: flex;
margin: auto;
outline: transparent;
position: relative;
width: 4em;
height: 4em;
transition: box-shadow calc(var(--trans-dur) / 2) var(--trans-timing);
appearance: none;
appearance: none;
tap-highlight-color: transparent;
&__badge {
background-color: hsl(var(--hue3),90%,50%);
border-radius: 0.75em;
box-shadow: 0 0.28125em 0.5625em hsla(var(--hue3),90%,30%,0.5);
overflow: hidden;
padding: 0 0.375em;
position: absolute;
top: 0;
right: 0;
min-width: 1.5em;
height: 1.5em;
transform: translate(0.625em,-0.625em);
transition: min-width var(--trans-dur) var(--trans-timing);
&-count,
&-text {
color: hsl(0,0%,100%);
font-weight: 300;
transition: opacity var(--trans-dur) var(--trans-timing);
}
&-count {
text-align: center;
}
&-text {
opacity: 0;
position: absolute;
top: 0;
left: 100%;
width: max-content;
}
&:has(&-count:empty) {
display: none;
}
}
&:focus-visible {
box-shadow:
0 0 0 0.333em hsla(0,0%,56%,1),
0 0.75em 1.5em hsla(var(--hue2),90%,30%,0.3);
}
&__icon {
color: var(--primary);
display: block;
overflow: visible;
pointer-events: none;
margin: auto;
width: 2.75em;
height: 2.75em;
}
&:hover &,
&:focus-visible & {
&__badge {
min-width: $badgeExpandedWidth;
&-count {
opacity: 0;
}
&-text {
animation: marquee 5s linear infinite;
opacity: 1;
}
}
}
&--animating &__icon {
&-dot {
animation: dot var(--dot-dur) map.get($timings,"ease-in-out");
&:nth-child(2) {
animation-delay: calc(var(--dot-dur) * 0.05);
}
&:nth-child(3) {
animation-delay: calc(var(--dot-dur) * 0.1);
}
}
}
}
/* Animations */
@keyframes dot {
from,
90%,
to {
animation-timing-function: map.get($timings,"ease-in-out");
transform: translateY(0);
}
30% {
animation-timing-function: map.get($timings,"ease-in");
transform: translateY(-32px);
}
60% {
animation-timing-function: map.get($timings,"ease-out");
transform: translateY(32px);
}
}
@keyframes marquee {
from {
transform: translateX(0);
}
to {
transform: translateX(calc(-100% - #{$badgeExpandedWidth}));
}
}
View Compiled
window.addEventListener("DOMContentLoaded",() => {
const app = new MessageApp(".app");
});
class MessageApp {
/** Element used for this component */
el: HTMLElement | null;
/** Number of messages */
messageCount = 1;
/** Preview of the first message */
messagePreview = "honey we need to talk…";
/** Animation is active */
isAnimating = false;
/** Class used for the animation state */
animationClass = "app--animating";
/** Events to trigger the animation */
downEvents = ["focus","mouseover","touchstart"];
/** Events to stop the animation */
upEvents = ["blur","mouseout","touchend"];
/**
* @param el CSS selector
*/
constructor(el: string) {
this.el = document.querySelector(el);
this.setupListeners();
this.displayBadge();
}
/** Run the animation upon user interaction. */
addAnimation(): void {
this.isAnimating = true;
this.checkInteraction();
}
/** Check if the user is still interacting before replaying the animation. */
checkInteraction(): void {
this.el?.classList.remove(this.animationClass);
if (this.isAnimating) {
void this.el?.offsetWidth;
this.el?.classList.add(this.animationClass);
}
}
/** Show the notification count and first message. */
displayBadge(): void {
const count = this.el?.querySelector("[data-count]") as HTMLElement;
const preview = this.el?.querySelector("[data-preview]") as HTMLElement;
if (count) {
count.innerText = this.messageCount > 0 ? `${this.messageCount}` : "";
count.ariaLabel = `${this.messageCount} message(s)`;
}
if (preview) {
preview.innerText = this.messagePreview;
}
}
/** Stop the animation after its last iteration. */
removeAnimation(): void {
this.isAnimating = false;
}
/** Set up the event listeners. */
setupListeners(): void {
for (let downEvent of this.downEvents) {
this.el?.addEventListener(downEvent,this.addAnimation.bind(this));
}
for (let upEvent of this.upEvents) {
this.el?.addEventListener(upEvent,this.removeAnimation.bind(this));
}
// use the last dot for checking user interaction
const dots = Array.from(this.el?.querySelectorAll("circle") || []);
const lastDot = dots.pop();
lastDot?.addEventListener("animationend",this.checkInteraction.bind(this));
}
}
View Compiled
This Pen doesn't use any external CSS resources.
This Pen doesn't use any external JavaScript resources.