03D-day Ring Filled From a Birthdate Value
The gap between a typed birthdate value and a typed reference date is computed in days to get both age and days until the next birthday, filling a circular ring by that ratio.
Best forSignup birthdate fields and birthday reminder cards
ageAtnextBirthdayGapdaysBetween

<main class="stage">
<div class="ar is-demo" id="ar">
<div class="ar__fields">
<label class="ar__field">
<span class="ar__cap">Date of birth</span>
<input class="ar__input" id="arBirth" type="date" value="1996-04-12" aria-describedby="arReadout">
</label>
<label class="ar__field">
<span class="ar__cap">Reference date</span>
<input class="ar__input" id="arToday" type="date" value="2026-09-23" aria-describedby="arReadout">
</label>
</div>
<div class="ar__ring" aria-hidden="true">
<svg viewBox="0 0 72 72">
<circle class="ar__track" cx="36" cy="36" r="30"></circle>
<circle class="ar__fill" id="arFill" cx="36" cy="36" r="30"></circle>
</svg>
<span class="ar__age" id="arAge">30</span>
</div>
<p class="ar__readout" id="arReadout" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(나이·디데이 산출, 링 채움 비율)은 index.html 의 ageAt()/nextBirthdayGap() 에 있다. 여기는 무대·레이아웃만.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-orange);
.ar {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
width: min(240px, 84vw);
background: $subject-cream;
border-radius: $r-card;
padding: $sp-4;
box-shadow: $shadow-raised;
}
.ar__fields {
display: flex;
gap: $sp-2;
width: 100%;
}
.ar__field {
display: flex;
flex-direction: column;
gap: $sp-1;
flex: 1 1 0;
}
.ar__cap {
color: $color;
font-size: 11px;
font-weight: 500;
}
.ar__input {
width: 100%;
border: 0;
border-radius: $r-control;
padding: $sp-1 $sp-2;
background: rgba($subject-ink, .08);
color: $subject-ink;
font-weight: 600;
font-size: 12px;
}
.ar__input:focus-visible {
outline: 2px solid $subject-blue;
outline-offset: 2px;
}
.ar__ring {
position: relative;
width: 84px;
height: 84px;
}
.ar__ring svg {
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.ar__track {
fill: none;
stroke: rgba($subject-ink, .14);
stroke-width: 6;
}
.ar__fill {
fill: none;
stroke: $subject-blue;
stroke-width: 6;
stroke-linecap: round;
transition: box-shadow $duration $easing;
will-change: stroke-dashoffset;
}
.ar__age {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: $color;
font-size: 20px;
font-weight: 700;
}
.ar__readout {
margin: 0;
color: $color;
font-size: 12px;
font-weight: 500;
line-height: 1.3;
text-align: center;
}
@media (max-width: 340px) {
.ar { padding: $sp-3; gap: $sp-1; width: min(220px, 84vw); }
.ar__cap { font-size: 10px; }
.ar__input { font-size: 11px; }
.ar__ring { width: 56px; height: 56px; }
.ar__age { font-size: 16px; }
.ar__readout { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.ar__fill { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ar {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: min(240px, 84vw);
background: #fff7e6;
border-radius: 16px;
padding: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.ar__fields {
display: flex;
gap: 8px;
width: 100%;
}
.ar__field {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1 1 0;
}
.ar__cap {
color: #17141a;
font-size: 11px;
font-weight: 500;
}
.ar__input {
width: 100%;
border: 0;
border-radius: 12px;
padding: 4px 8px;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
font-weight: 600;
font-size: 12px;
}
.ar__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.ar__ring {
position: relative;
width: 84px;
height: 84px;
}
.ar__ring svg {
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.ar__track {
fill: none;
stroke: rgba(23, 20, 26, 0.14);
stroke-width: 6;
}
.ar__fill {
fill: none;
stroke: #2f6df6;
stroke-width: 6;
stroke-linecap: round;
transition: box-shadow 2s cubic-bezier(0.2, 0.8, 0.2, 1);
will-change: stroke-dashoffset;
}
.ar__age {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #17141a;
font-size: 20px;
font-weight: 700;
}
.ar__readout {
margin: 0;
color: #17141a;
font-size: 12px;
font-weight: 500;
line-height: 1.3;
text-align: center;
}
@media (max-width: 340px) {
.ar {
padding: 12px;
gap: 4px;
width: min(220px, 84vw);
}
.ar__cap {
font-size: 10px;
}
.ar__input {
font-size: 11px;
}
.ar__ring {
width: 56px;
height: 56px;
}
.ar__age {
font-size: 16px;
}
.ar__readout {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.ar__fill {
transition: none;
}
}(function () {
var root = document.getElementById('ar');
var birthInput = document.getElementById('arBirth');
var refInput = document.getElementById('arToday');
var fill = document.getElementById('arFill');
var ageEl = document.getElementById('arAge');
var readout = document.getElementById('arReadout');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var isDemo = true;
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
var RADIUS = 30;
var CIRCUMFERENCE = 2 * Math.PI * RADIUS;
fill.style.strokeDasharray = CIRCUMFERENCE.toFixed(2);
function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
function parseYmd(s) {
var p = s.split('-');
return { y: parseInt(p[0], 10), m: parseInt(p[1], 10), d: parseInt(p[2], 10) };
}
function daysBetween(a, b) {
var msPerDay = 86400000;
var da = Date.UTC(a.y, a.m - 1, a.d);
var db = Date.UTC(b.y, b.m - 1, b.d);
return Math.round((db - da) / msPerDay);
}
// 계산 한 겹 — Date of birth·기준일 두 값의 날짜 차이로 나이와 다음 생일까지 남은 날, 그 비율(링 채움)을 구한다
function ageAt(birth, ref) {
var age = ref.y - birth.y;
var hadBirthdayThisYear = (ref.m > birth.m) || (ref.m === birth.m && ref.d >= birth.d);
if (!hadBirthdayThisYear) age -= 1;
return age;
}
function nextBirthdayGap(birth, ref) {
var nextY = ref.y;
var thisYearBday = { y: ref.y, m: birth.m, d: birth.d };
var gapThisYear = daysBetween(ref, thisYearBday);
if (gapThisYear < 0) { nextY = ref.y + 1; }
var target = { y: nextY, m: birth.m, d: birth.d };
return daysBetween(ref, target);
}
var SENT_PREFIX = 'You are ', SENT_MID = ' years old, and the next birthday is in ', SENT_SUFFIX = ' days.';
var targetT = 0, renderedT = 0;
var LERP_RATE = 0.13; // 변형 03: 낮추면 링이 값 변화를 더 천천히 뒤따라간다
function applyValues() {
var birth = parseYmd(birthInput.value);
var ref = parseYmd(refInput.value);
var age = ageAt(birth, ref);
var gap = nextBirthdayGap(birth, ref);
var yearLen = 365.2425;
targetT = clamp01(1 - gap / yearLen);
ageEl.textContent = age;
readout.textContent = SENT_PREFIX + age + SENT_MID + gap + SENT_SUFFIX;
if (reduce) { renderedT = targetT; paint(); }
}
function paint() {
var offset = CIRCUMFERENCE * (1 - renderedT);
fill.style.strokeDashoffset = offset.toFixed(2);
}
birthInput.addEventListener('input', function () { human(); applyValues(); });
birthInput.addEventListener('change', function () { human(); applyValues(); });
refInput.addEventListener('input', function () { human(); applyValues(); });
refInput.addEventListener('change', function () { human(); applyValues(); });
var DEMO_BIRTHDAYS = ['1996-04-12', '1990-12-30', '2001-09-24', '1988-02-05', '2015-06-18'];
var DRIVE_PERIOD_MS = 2000;
var driveStart = 0, lastIdx = -1;
function driveTick(now) {
if (!driveStart) driveStart = now;
if (isDemo) {
var frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
var idx = Math.floor(frac * DEMO_BIRTHDAYS.length);
if (idx !== lastIdx) { lastIdx = idx; birthInput.value = DEMO_BIRTHDAYS[idx]; applyValues(); }
}
requestAnimationFrame(driveTick);
}
function frame() { renderedT += (targetT - renderedT) * LERP_RATE; paint(); requestAnimationFrame(frame); }
applyValues();
if (!reduce) { requestAnimationFrame(driveTick); requestAnimationFrame(frame); }
})();import { useEffect, useRef } from "react";
import styles from "./AgeRingCountdown.module.scss";
type Ymd = { y: number; m: number; d: number };
export default function AgeRingCountdown() {
const rootRef = useRef<HTMLDivElement | null>(null);
const birthRef = useRef<HTMLInputElement | null>(null);
const refRef = useRef<HTMLInputElement | null>(null);
const fillRef = useRef<SVGCircleElement | null>(null);
const ageRef = useRef<HTMLSpanElement | null>(null);
const readoutRef = useRef<HTMLParagraphElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const birthInput = birthRef.current;
const refInput = refRef.current;
const fill = fillRef.current;
const ageEl = ageRef.current;
const readout = readoutRef.current;
if (!root || !birthInput || !refInput || !fill || !ageEl || !readout) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let isDemo = true;
function human() {
if (isDemo) {
root!.classList.remove("is-demo");
isDemo = false;
}
}
const RADIUS = 30;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
fill.style.strokeDasharray = CIRCUMFERENCE.toFixed(2);
function clamp01(v: number) {
return v < 0 ? 0 : v > 1 ? 1 : v;
}
function parseYmd(s: string): Ymd {
const p = s.split("-");
return { y: parseInt(p[0], 10), m: parseInt(p[1], 10), d: parseInt(p[2], 10) };
}
function daysBetween(a: Ymd, b: Ymd) {
const msPerDay = 86400000;
const da = Date.UTC(a.y, a.m - 1, a.d);
const db = Date.UTC(b.y, b.m - 1, b.d);
return Math.round((db - da) / msPerDay);
}
function ageAt(birth: Ymd, ref: Ymd) {
let age = ref.y - birth.y;
const hadBirthdayThisYear = ref.m > birth.m || (ref.m === birth.m && ref.d >= birth.d);
if (!hadBirthdayThisYear) age -= 1;
return age;
}
function nextBirthdayGap(birth: Ymd, ref: Ymd) {
let nextY = ref.y;
const thisYearBday: Ymd = { y: ref.y, m: birth.m, d: birth.d };
const gapThisYear = daysBetween(ref, thisYearBday);
if (gapThisYear < 0) nextY = ref.y + 1;
const target: Ymd = { y: nextY, m: birth.m, d: birth.d };
return daysBetween(ref, target);
}
const SENT_PREFIX = "만 ";
const SENT_MID = "세이고, 다음 생일까지 ";
const SENT_SUFFIX = "일 남았습니다";
let targetT = 0;
let renderedT = 0;
const LERP_RATE = 0.13;
function applyValues() {
const birth = parseYmd(birthInput!.value);
const ref = parseYmd(refInput!.value);
const age = ageAt(birth, ref);
const gap = nextBirthdayGap(birth, ref);
const yearLen = 365.2425;
targetT = clamp01(1 - gap / yearLen);
ageEl!.textContent = String(age);
readout!.textContent = SENT_PREFIX + age + SENT_MID + gap + SENT_SUFFIX;
if (reduce) {
renderedT = targetT;
paint();
}
}
function paint() {
const offset = CIRCUMFERENCE * (1 - renderedT);
fill!.style.strokeDashoffset = offset.toFixed(2);
}
function onInput() {
human();
applyValues();
}
birthInput.addEventListener("input", onInput);
birthInput.addEventListener("change", onInput);
refInput.addEventListener("input", onInput);
refInput.addEventListener("change", onInput);
const DEMO_BIRTHDAYS = ["1996-04-12", "1990-12-30", "2001-09-24", "1988-02-05", "2015-06-18"];
const DRIVE_PERIOD_MS = 2000;
let driveStart = 0;
let lastIdx = -1;
let rafDrive = 0;
function driveTick(now: number) {
if (!driveStart) driveStart = now;
if (isDemo) {
const frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
const idx = Math.floor(frac * DEMO_BIRTHDAYS.length);
if (idx !== lastIdx) {
lastIdx = idx;
birthInput!.value = DEMO_BIRTHDAYS[idx];
applyValues();
}
}
rafDrive = requestAnimationFrame(driveTick);
}
let rafFrame = 0;
function frame() {
renderedT += (targetT - renderedT) * LERP_RATE;
paint();
rafFrame = requestAnimationFrame(frame);
}
applyValues();
if (!reduce) {
rafDrive = requestAnimationFrame(driveTick);
rafFrame = requestAnimationFrame(frame);
}
return () => {
birthInput.removeEventListener("input", onInput);
birthInput.removeEventListener("change", onInput);
refInput.removeEventListener("input", onInput);
refInput.removeEventListener("change", onInput);
if (rafDrive) cancelAnimationFrame(rafDrive);
if (rafFrame) cancelAnimationFrame(rafFrame);
};
}, []);
return (
<div ref={rootRef} className={`${styles.ar} is-demo`}>
<div className={styles.fields}>
<label className={styles.field}>
<span className={styles.cap}>생년월일</span>
<input ref={birthRef} className={styles.input} type="date" defaultValue="1996-04-12" aria-describedby="arReadoutReact" />
</label>
<label className={styles.field}>
<span className={styles.cap}>기준 날짜</span>
<input ref={refRef} className={styles.input} type="date" defaultValue="2026-09-23" aria-describedby="arReadoutReact" />
</label>
</div>
<div className={styles.ring} aria-hidden="true">
<svg viewBox="0 0 72 72">
<circle className={styles.track} cx={36} cy={36} r={30} />
<circle ref={fillRef} className={styles.fill} cx={36} cy={36} r={30} />
</svg>
<span ref={ageRef} className={styles.age}>30</span>
</div>
<p ref={readoutRef} className={styles.readout} id="arReadoutReact" role="status" aria-live="polite" />
</div>
);
}03 D-day Ring Filled From a Birthdate Value — Measured area 2.981% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · CSS Calculator Design: 9 Inputs, Unit Converter UI
The post this demo lives in
CSS Calculator Design: 9 Inputs, Unit Converter UIAmong nine css calculator design inputs, one draws a QR grid from a text value, one fills a ring gauge from a birthdate See all demos in this post
If you like this effect - similar demos
Inputs04 macOS crayon — a grid color picker that sets its border color from a brightness value9 Color Picker Design Patterns (Copy-Paste)
Inputs03 Entropy-bit value display — the strength value comes from a calculation multiplying the character-set size value by the length valuePassword Strength Meter UI: 9 Calculation Patterns
Inputs01 Check-draw toggle — when the checked value turns on, the checkmark's line-length value draws from zero to full in orderCSS Custom Checkbox: 9 Interaction Patterns