Skip to content

Instantly share code, notes, and snippets.

@rsp2k
Created September 16, 2025 02:45
Show Gist options
  • Save rsp2k/f14fae416ecd94dd8a915302e9b82f1b to your computer and use it in GitHub Desktop.
Save rsp2k/f14fae416ecd94dd8a915302e9b82f1b to your computer and use it in GitHub Desktop.
Financial Score Widgets

Financial Score Widgets

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.

License.

<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&amp;display=swap" rel="stylesheet" />
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment