An animated entrance sequence for widgets where everything rolls in including the numbers. Design based on a Dribbble shot by Mateusz Nieckarz.
A Pen by Jon Kantner on CodePen.
An animated entrance sequence for widgets where everything rolls in including the numbers. Design based on a Dribbble shot by Mateusz Nieckarz.
A Pen by Jon Kantner on CodePen.
<div id="root"></div> |
import React, { StrictMode, createContext, useCallback, useContext, useEffect, useRef, useState } from "https://esm.sh/react"; | |
import { createRoot } from "https://esm.sh/react-dom/client"; | |
const data: FinancialScoreProps[] = [ | |
{ | |
title: "Protection Score", | |
description: "This score measures your overall security strength. Higher score means better protection. Aim to maintain or improve.", | |
initialScore: 42 | |
}, | |
{ | |
title: "Investment Score", | |
description: "This score measures portfolio alignment with your goals and strategy. Higher score indicates better performance.", | |
initialScore: 83 | |
}, | |
{ | |
title: "Financial Fitness", | |
description: "Boost financial control in 10 minutes. Get your fitness score—quick, free, no impact on credit or mortgages." | |
} | |
]; | |
const CounterContext = createContext<CounterContextType | undefined>(undefined); | |
const CounterProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { | |
const counterRef = useRef(0); | |
const getNextIndex = useCallback(() => { | |
return counterRef.current++; | |
}, []); | |
return ( | |
<CounterContext.Provider value={{ getNextIndex }}> | |
{children} | |
</CounterContext.Provider> | |
); | |
}; | |
const useCounter = () => { | |
const context = useContext(CounterContext); | |
if (!context) { | |
throw new Error("useCounter must be used within a CounterProvider"); | |
} | |
return context.getNextIndex; | |
}; | |
createRoot(document.getElementById("root")!).render( | |
<StrictMode> | |
<main> | |
<CounterProvider> | |
{data.map((card, i) => <FinancialScore key={i} {...card} />)} | |
</CounterProvider> | |
</main> | |
</StrictMode> | |
); | |
function FinancialScore({ title, description, initialScore }: FinancialScoreProps) { | |
const [score, setScore] = useState<Score>(initialScore ?? null); | |
const hasScore = score !== null; | |
const max = 100; | |
const strength = Utils.getStrength(score, max); | |
/** Generate a random score if not already. */ | |
function handleGenerateScore(): void { | |
if (!hasScore) { | |
setScore(Utils.randomInt(0, max)); | |
} | |
} | |
return ( | |
<FinancialScoreCard> | |
<FinancialScoreHeader title={title} strength={strength} /> | |
<div className="card__graph-container"> | |
<FinancialScoreHalfCircle value={score} max={max} /> | |
<FinancialScoreDisplay value={score} max={max} /> | |
</div> | |
<p className="card__description">{description}</p> | |
<FinancialScoreButton | |
isOutlined={hasScore} | |
onClick={handleGenerateScore} | |
> | |
{hasScore ? "Learn more" : "Calculate your score"} | |
</FinancialScoreButton> | |
</FinancialScoreCard> | |
); | |
} | |
function FinancialScoreButton({ children, isOutlined, onClick }: FinancialScoreButtonProps) { | |
const buttonOutline = isOutlined ? " card__score-button--outlined" : ""; | |
const buttonClass = `card__score-button${buttonOutline}`; | |
return ( | |
<button className={buttonClass} type="button" onClick={onClick}> | |
{children} | |
</button> | |
); | |
} | |
function FinancialScoreCard({ children }: FinancialScoreCardProps) { | |
const getNextIndex = useCounter(); | |
const indexRef = useRef<number | null>(null); | |
const animationRef = useRef(0); | |
const containerRef = useRef<HTMLDivElement>(null); | |
const [animating, setAnimating] = useState(false); | |
const [appearing, setAppearing] = useState(false); | |
const cardStyle = animating ? { height: `${containerRef.current?.scrollHeight}px` } : {}; | |
if (indexRef.current === null) { | |
indexRef.current = getNextIndex(); | |
} | |
// delay the appearance as part of a staggering effect | |
useEffect(() => { | |
const delayInc = 200; | |
const delay = 300 + indexRef.current! * delayInc; | |
animationRef.current = setTimeout(() => setAppearing(true), delay); | |
return () => { | |
clearTimeout(animationRef.current); | |
}; | |
}, []); | |
// animate the container height | |
useEffect(() => { | |
if (appearing) { | |
setAnimating(true); | |
const animContainer = containerRef.current?.animate( | |
[ | |
{ height: 0 }, | |
{ height: `${containerRef.current?.scrollHeight}px` } | |
], | |
{ | |
duration: 800, | |
easing: Utils.easings.easeOut | |
} | |
); | |
if (animContainer) { | |
animContainer.onfinish = () => { | |
setAnimating(false); | |
}; | |
} | |
} | |
}, [appearing]); | |
return ( | |
<div className="card" style={cardStyle}> | |
{ | |
appearing && | |
<div className="card__surface"> | |
<div className="card__container" ref={containerRef}> | |
<div className="card__content"> | |
{children} | |
</div> | |
</div> | |
</div> | |
} | |
</div> | |
); | |
} | |
function FinancialScoreDisplay({ value, max }: FinancialScoreDisplayProps) { | |
const hasValue = value !== null; | |
const scoreAnimated = hasValue ? " card__score--animated" : ""; | |
const scoreClass = `card__score${scoreAnimated}`; | |
const digits = String(Math.floor(value!)).split(""); | |
const maxFormatted = Utils.formatNumber(max); | |
const label = hasValue ? `out of ${maxFormatted}` : "No score"; | |
return ( | |
<div className="card__score-display"> | |
<div className={scoreClass}> | |
<div className="card__score-digits card__score-digits--dimmed"> | |
<div className="card__score-digit">0</div> | |
</div> | |
<div className="card__score-digits"> | |
{hasValue && digits.map((digit, i) => ( | |
<span key={i} className="card__score-digit">{digit}</span> | |
))} | |
</div> | |
</div> | |
<div className="card__score-label">{label}</div> | |
</div> | |
); | |
} | |
function FinancialScoreHalfCircle({ value, max }: FinancialScoreHalfCircleProps) { | |
const strokeRef = useRef<SVGCircleElement>(null); | |
const gradIdRef = useRef(`grad-${Utils.randomHash()}`); | |
const gradId = gradIdRef.current; | |
const gradStroke = `url(#${gradId})`; | |
const radius = 45; | |
const dist = Utils.circumference(radius); | |
const distHalf = dist / 2; | |
const distFourth = distHalf / 2; | |
const strokeDasharray = `${distHalf} ${distHalf}`; | |
const distForValue = Math.min(value as number / max, 1) * -distHalf; | |
const strokeDashoffset = value !== null ? distForValue : -distFourth; | |
const strength = Utils.getStrength(value, max); | |
const strengthColors: StrengthColors = { | |
none: [ | |
"light-dark(var(--gray50), var(--gray800))", | |
"light-dark(var(--gray400), var(--gray600))" | |
], | |
weak: [ | |
"light-dark(var(--danger200), var(--danger700))", | |
"var(--danger500)", | |
"light-dark(var(--danger700), var(--danger200))" | |
], | |
moderate: [ | |
"light-dark(var(--warning200), var(--warning700))", | |
"var(--warning500)", | |
"light-dark(var(--warning600), var(--warning200))" | |
], | |
strong: [ | |
"light-dark(var(--success200), var(--success800))", | |
"var(--success500)", | |
"light-dark(var(--success700), var(--success300))" | |
] | |
}; | |
const colorStops = strengthColors[strength]; | |
useEffect(() => { | |
const strokeStart = 400; | |
const duration = 1400; | |
strokeRef.current?.animate( | |
[ | |
{ strokeDashoffset: 0, offset: 0 }, | |
{ strokeDashoffset: 0, offset: strokeStart / duration }, | |
{ strokeDashoffset } | |
], | |
{ | |
duration, | |
easing: Utils.easings.easeInOut, | |
fill: "forwards" | |
} | |
); | |
}, [value, max]); | |
return ( | |
<svg className="card__half-circle" viewBox="0 0 100 50" aria-hidden="true"> | |
<defs> | |
<linearGradient id={gradId} x1="0" y1="0" x2="1" y2="0"> | |
{colorStops.map((stop, i) => { | |
const offset = `${100 / (colorStops.length - 1) * i}%`; | |
return <stop key={i} offset={offset} stopColor={stop} />; | |
})} | |
</linearGradient> | |
</defs> | |
<g fill="none" strokeWidth="10" transform="translate(50, 50.5)"> | |
<circle className="card__half-circle-track" r={radius} /> | |
<circle | |
ref={strokeRef} | |
stroke={gradStroke} | |
strokeDasharray={strokeDasharray} | |
r={radius} | |
/> | |
</g> | |
</svg> | |
); | |
} | |
function FinancialScoreHeader({ title, strength }: FinancialScoreHeaderProps) { | |
const badgeClass = `card__badge card__badge--${strength}`; | |
const hasStrength = strength !== Strength.None; | |
return ( | |
<div className="card__header"> | |
<h2 className="card__title">{title}</h2> | |
{hasStrength && <span className={badgeClass}>{strength}</span>} | |
</div> | |
); | |
} | |
class Utils { | |
static LOCALE = "en-US"; | |
/** Easings for animations */ | |
static easings = { | |
easeInOut: "cubic-bezier(0.65, 0, 0.35, 1)", | |
easeOut: "cubic-bezier(0.33, 1, 0.68, 1)" | |
}; | |
/** | |
* Get the circumference of a circle with a given radius. | |
* @param r radius | |
*/ | |
static circumference(r: number): number { | |
return 2 * Math.PI * r; | |
} | |
/** | |
* Format any kind of number to a localized format. | |
* @param n number | |
*/ | |
static formatNumber(n: number) { | |
return new Intl.NumberFormat(this.LOCALE).format(n); | |
} | |
/** | |
* Get the strength level based on a score. | |
* @param score score value | |
* @param maxScore max score | |
*/ | |
static getStrength(score: Score, maxScore: number): Strength { | |
if (!score) return Strength.None; | |
const percent = score / maxScore; | |
if (percent >= 0.8) return Strength.Strong; | |
if (percent >= 0.4) return Strength.Moderate; | |
return Strength.Weak; | |
} | |
/** | |
* Generate a random hash for uniquely identifying entities. | |
* @param length number of characters | |
*/ | |
static randomHash(length: number = 4): string { | |
const chars = "abcdef0123456789"; | |
const bytes = crypto.getRandomValues(new Uint8Array(length)); | |
return [...bytes].map(b => chars[b % chars.length]).join(""); | |
} | |
/** | |
* Get a random integer between two values. | |
* @param min minimum value | |
* @param max maximum value | |
*/ | |
static randomInt(min: number = 0, max: number = 1): number { | |
const value = crypto.getRandomValues(new Uint32Array(1))[0] / 2 ** 32; | |
return Math.round(min + (max - min) * value); | |
} | |
} | |
// enums | |
enum Strength { | |
None = "none", | |
Weak = "weak", | |
Moderate = "moderate", | |
Strong = "strong" | |
}; | |
// interfaces | |
interface FinancialScoreProps { | |
title: string; | |
description: string; | |
initialScore?: number; | |
}; | |
interface FinancialScoreButtonProps { | |
children?: React.ReactNode; | |
isOutlined?: boolean; | |
onClick?: () => void; | |
}; | |
interface FinancialScoreCardProps { | |
children?: React.ReactNode; | |
}; | |
interface FinancialScoreDisplayProps { | |
value: Score; | |
max: number; | |
}; | |
interface FinancialScoreHalfCircleProps { | |
value: Score; | |
max: number; | |
}; | |
interface FinancialScoreHeaderProps { | |
title?: string; | |
strength?: Strength; | |
}; | |
// types | |
type CounterContextType = { | |
getNextIndex: () => number; | |
}; | |
type Score = number | null; | |
type StrengthColors = Record<Strength, string[]>; |
* { | |
border: 0; | |
box-sizing: border-box; | |
margin: 0; | |
padding: 0; | |
} | |
:root { | |
--hue: 223; | |
--sat: 10%; | |
--white: hsl(0, 0%, 100%); | |
--gray50: hsl(var(--hue), var(--sat), 95%); | |
--gray100: hsl(var(--hue), var(--sat), 90%); | |
--gray200: hsl(var(--hue), var(--sat), 80%); | |
--gray300: hsl(var(--hue), var(--sat), 70%); | |
--gray400: hsl(var(--hue), var(--sat), 60%); | |
--gray500: hsl(var(--hue), var(--sat), 50%); | |
--gray600: hsl(var(--hue), var(--sat), 40%); | |
--gray700: hsl(var(--hue), var(--sat), 30%); | |
--gray750: hsl(var(--hue), var(--sat), 25%); | |
--gray800: hsl(var(--hue), var(--sat), 20%); | |
--gray900: hsl(var(--hue), var(--sat), 10%); | |
--gray950: hsl(var(--hue), var(--sat), 5%); | |
--danger100: hsl(3, 77%, 90%); | |
--danger200: hsl(3, 77%, 80%); | |
--danger500: hsl(3, 77%, 50%); | |
--danger700: hsl(3, 77%, 30%); | |
--danger800: hsl(3, 77%, 20%); | |
--warning100: hsl(33, 94%, 90%); | |
--warning200: hsl(33, 94%, 80%); | |
--warning500: hsl(33, 94%, 50%); | |
--warning600: hsl(33, 94%, 40%); | |
--warning700: hsl(33, 94%, 30%); | |
--warning800: hsl(33, 94%, 20%); | |
--success100: hsl(153, 60%, 90%); | |
--success200: hsl(153, 60%, 80%); | |
--success300: hsl(153, 60%, 70%); | |
--success500: hsl(153, 60%, 50%); | |
--success700: hsl(153, 60%, 30%); | |
--success800: hsl(153, 60%, 20%); | |
--primary200: hsl(223, 90%, 80%); | |
--primary500: hsl(223, 90%, 50%); | |
--primary600: hsl(223, 90%, 40%); | |
--trans-dur: 0.3s; | |
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); | |
--ease-out: cubic-bezier(0.33, 1, 0.68, 1); | |
color-scheme: light dark; | |
font-size: clamp(0.75rem, 0.7rem + 0.25vw, 1rem); | |
} | |
#root { | |
width: 100%; | |
} | |
body, | |
button { | |
color: light-dark(var(--gray900), var(--gray100)); | |
font: 1em/1.5 Inter, sans-serif; | |
} | |
body { | |
background-color: light-dark(var(--gray50), var(--gray950)); | |
display: grid; | |
place-items: center; | |
height: 100vh; | |
transition: | |
background-color var(--trans-dur), | |
color var(--trans-dur); | |
} | |
button { | |
-webkit-appearance: none; | |
appearance: none; | |
-webkit-tap-highlight-color: transparent; | |
} | |
main { | |
display: flex; | |
flex-wrap: wrap; | |
justify-content: center; | |
gap: 3em; | |
margin: auto; | |
padding: 1.5em 0; | |
width: calc(100% - 3em); | |
} | |
.card { | |
display: flex; | |
align-items: center; | |
width: 100%; | |
max-width: 25em; | |
&__surface { | |
animation: fade-in 1s var(--ease-out); | |
background: light-dark(var(--white), var(--gray900)); | |
border-radius: 1em; | |
box-shadow: 0 0.25em 0.625em hsla(0, 0%, 0%, 0.05); | |
min-width: 0; | |
transition: background-color var(--trans-dur); | |
} | |
&__header, | |
&__badge, | |
&__graph-container, | |
&__description, | |
&__score-button { | |
animation: fade-slide-up 0.8s var(--ease-out) forwards; | |
opacity: 0; | |
} | |
&__header { | |
display: flex; | |
gap: 1.5em; | |
justify-content: space-between; | |
align-items: center; | |
margin-bottom: 2.5em; | |
} | |
&__title { | |
font-size: 1.375em; | |
font-weight: 500; | |
overflow: hidden; | |
text-overflow: ellipsis; | |
white-space: nowrap; | |
} | |
&__badge { | |
animation-delay: 0.8s; | |
border-radius: 0.25rem; | |
flex-shrink: 0; | |
font-size: 0.75em; | |
font-weight: 600; | |
line-height: 2; | |
opacity: 0; | |
padding: 0.25rem 0.625rem; | |
text-transform: uppercase; | |
transform: translateY(67%); | |
transition: | |
background-color var(--trans-dur), | |
color var(--trans-dur); | |
&--weak { | |
background-color: light-dark(var(--danger100), var(--danger700)); | |
color: light-dark(var(--danger700), var(--danger100)); | |
} | |
&--moderate { | |
background-color: light-dark(var(--warning100), var(--warning800)); | |
color: light-dark(var(--warning700), var(--warning200)); | |
} | |
&--strong { | |
background-color: light-dark(var(--success100), var(--success800)); | |
color: light-dark(var(--success700), var(--success100)); | |
} | |
} | |
&__graph-container { | |
animation-delay: 0.1s; | |
position: relative; | |
margin-bottom: 2em; | |
} | |
&__half-circle { | |
display: block; | |
margin: auto; | |
width: auto; | |
max-width: 100%; | |
height: 8.5em; | |
[dir="rtl"] & { | |
transform: scaleX(-1); | |
} | |
&-track { | |
stroke: light-dark(var(--gray50), var(--gray800)); | |
transition: stroke var(--trans-dur); | |
} | |
} | |
&__description { | |
animation-delay: 0.2s; | |
color: light-dark(var(--gray600), var(--gray400)); | |
margin-bottom: 2.25em; | |
min-height: 4.5em; | |
text-align: center; | |
transition: color var(--trans-dur); | |
} | |
&__score { | |
font-size: 2.5em; | |
font-weight: 500; | |
height: 3.75rem; | |
overflow: hidden; | |
position: relative; | |
&-digit { | |
display: inline-block; | |
} | |
&-digits { | |
direction: ltr; | |
position: absolute; | |
inset: 0; | |
&--dimmed { | |
color: light-dark(var(--gray400), var(--gray500)); | |
} | |
& + & { | |
transform: translateY(100%); | |
} | |
} | |
&--animated &-digit { | |
animation: slide-to-top 0.8s 0.4s var(--ease-in-out) forwards; | |
@for $d from 2 through 3 { | |
&:nth-child(#{$d}) { | |
$inc: $d - 1; | |
animation: { | |
duration: 0.8s + (0.3 * $inc); | |
delay: 0.4s + 0.1 * $inc; | |
}; | |
} | |
} | |
} | |
&-display { | |
position: absolute; | |
bottom: 0; | |
width: 100%; | |
text-align: center; | |
} | |
&-label { | |
color: light-dark(var(--gray700), var(--gray300)); | |
text-transform: uppercase; | |
transition: color var(--trans-dur); | |
} | |
&-button { | |
animation-delay: 0.3s; | |
background-color: var(--primary500); | |
border: 2px solid var(--primary500); | |
border-radius: 0.5rem; | |
box-shadow: 0 0 0 3px transparent; | |
color: var(--white); | |
cursor: pointer; | |
font-size: 1.25em; | |
outline: transparent; | |
padding: 0.625rem 0.75rem; | |
width: 100%; | |
transition: | |
background-color var(--trans-dur), | |
border-color var(--trans-dur), | |
box-shadow var(--trans-dur), | |
color var(--trans-dur); | |
&:focus-visible { | |
box-shadow: 0 0 0 3px var(--primary200); | |
} | |
&:hover { | |
background-color: var(--primary600); | |
border-color: var(--primary600); | |
} | |
&--outlined { | |
background: transparent; | |
border: 2px solid light-dark(var(--gray100), var(--gray700)); | |
color: light-dark(var(--gray900), var(--gray100)); | |
&:hover { | |
background: light-dark(var(--gray100), var(--gray800)); | |
border-color: light-dark(var(--gray200), var(--gray600)); | |
} | |
} | |
} | |
} | |
&__container { | |
overflow: hidden; | |
} | |
&__content { | |
padding: 2.25em; | |
} | |
} | |
/* Animations */ | |
@keyframes fade-in { | |
from { | |
opacity: 0; | |
} | |
to { | |
opacity: 1; | |
} | |
} | |
@keyframes fade-slide-up { | |
from { | |
opacity: 0; | |
transform: translateY(3em); | |
} | |
to { | |
opacity: 1; | |
transform: translateY(0); | |
} | |
} | |
@keyframes slide-to-top { | |
from { | |
transform: translateY(0); | |
} | |
to { | |
transform: translateY(-100%); | |
} | |
} |
<link href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap" rel="stylesheet" /> |