08Tip-split Ring That Divides a Share From a Headcount Value
A typed total and headcount value are divided to compute the per-person share, filling a slice of a circular ring by that share's proportion of the total.
Best forReceipt-splitting apps and group-expense forms
perPersonShareshareFractionformatNumber

<main class="stage">
<div class="ts is-demo" id="ts">
<div class="ts__fields">
<div class="ts__field">
<span class="ts__cap">Total amount</span>
<input class="ts__total" id="tsTotal" type="number" inputmode="numeric" min="0" step="1000" value="42000" aria-label="Entering the total amount also calculates the per-person share with the headcount and fills the ring segment" aria-describedby="tsReadout">
</div>
<div class="ts__field">
<span class="ts__cap">Headcount</span>
<input class="ts__count" id="tsCount" type="number" inputmode="numeric" min="1" max="12" step="1" value="3" aria-label="Entering the headcount also calculates the per-person share with the total amount and fills the ring segment" aria-describedby="tsReadout">
</div>
</div>
<div class="ts__ring">
<svg viewBox="0 0 72 72" aria-hidden="true">
<circle class="ts__track" cx="36" cy="36" r="30"></circle>
<circle class="ts__fill" id="tsFill" cx="36" cy="36" r="30"></circle>
</svg>
<span class="ts__share" id="tsShare" aria-hidden="true"></span>
</div>
<p class="ts__readout" id="tsReadout" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(1인당 분담액, 링 조각 비율)은 index.html 의 perPersonShare()/shareFraction() 에 있다. 여기는 무대·레이아웃만.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-paper);
.ts {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
width: min(240px, 84vw);
background: $subject-blue-deep;
border-radius: $r-card;
padding: $sp-4;
box-shadow: $shadow-raised;
}
.ts__fields {
display: flex;
gap: $sp-2;
width: 100%;
}
.ts__field {
display: flex;
flex-direction: column;
gap: $sp-1;
flex: 1 1 0;
}
.ts__cap {
color: $color;
font-size: 11px;
font-weight: 500;
}
.ts__total,
.ts__count {
width: 100%;
border: 0;
border-radius: $r-control;
padding: $sp-1 $sp-2;
background: $subject-cream;
color: $subject-ink;
font-weight: 700;
font-size: 13px;
text-align: center;
}
.ts__total:focus-visible,
.ts__count:focus-visible {
outline: 2px solid $color;
outline-offset: 2px;
}
.ts__ring {
position: relative;
width: 76px;
height: 76px;
}
.ts__ring svg {
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.ts__track {
fill: none;
stroke: rgba($subject-cream, .18);
stroke-width: 6;
}
.ts__fill {
fill: none;
stroke: $subject-cream;
stroke-width: 6;
stroke-linecap: round;
transition: box-shadow $duration $easing;
will-change: stroke-dashoffset;
}
.ts__share {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: $color;
font-size: 15px;
font-weight: 700;
}
.ts__readout {
margin: 0;
color: $color;
font-size: 12px;
font-weight: 500;
line-height: 1.3;
text-align: center;
}
@media (max-width: 340px) {
.ts { padding: $sp-3; gap: $sp-1; width: min(220px, 84vw); }
.ts__fields { gap: $sp-1; }
.ts__cap { font-size: 10px; }
.ts__total, .ts__count { font-size: 11px; }
.ts__ring { width: 56px; height: 56px; }
.ts__share { font-size: 12px; }
.ts__readout { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.ts__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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ts {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: min(240px, 84vw);
background: #0a4fe8;
border-radius: 16px;
padding: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.ts__fields {
display: flex;
gap: 8px;
width: 100%;
}
.ts__field {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1 1 0;
}
.ts__cap {
color: #fff7e6;
font-size: 11px;
font-weight: 500;
}
.ts__total,
.ts__count {
width: 100%;
border: 0;
border-radius: 12px;
padding: 4px 8px;
background: #fff7e6;
color: #17141a;
font-weight: 700;
font-size: 13px;
text-align: center;
}
.ts__total:focus-visible,
.ts__count:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.ts__ring {
position: relative;
width: 76px;
height: 76px;
}
.ts__ring svg {
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.ts__track {
fill: none;
stroke: rgba(255, 247, 230, 0.18);
stroke-width: 6;
}
.ts__fill {
fill: none;
stroke: #fff7e6;
stroke-width: 6;
stroke-linecap: round;
transition: box-shadow 2s cubic-bezier(0.2, 0.8, 0.2, 1);
will-change: stroke-dashoffset;
}
.ts__share {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #fff7e6;
font-size: 15px;
font-weight: 700;
}
.ts__readout {
margin: 0;
color: #fff7e6;
font-size: 12px;
font-weight: 500;
line-height: 1.3;
text-align: center;
}
@media (max-width: 340px) {
.ts {
padding: 12px;
gap: 4px;
width: min(220px, 84vw);
}
.ts__fields {
gap: 4px;
}
.ts__cap {
font-size: 10px;
}
.ts__total, .ts__count {
font-size: 11px;
}
.ts__ring {
width: 56px;
height: 56px;
}
.ts__share {
font-size: 12px;
}
.ts__readout {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.ts__fill {
transition: none;
}
}(function () {
var root = document.getElementById('ts');
var totalInput = document.getElementById('tsTotal');
var countInput = document.getElementById('tsCount');
var fill = document.getElementById('tsFill');
var shareEl = document.getElementById('tsShare');
var readout = document.getElementById('tsReadout');
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);
// 계산 한 겹 — 1인당 분담액, 링 조각 비율(=1/Headcount)
function perPersonShare(total, n) { return n > 0 ? total / n : 0; }
function shareFraction(n) { return n > 0 ? 1 / n : 0; }
function formatNumber(v) {
var s = Math.round(v).toString();
var out = '';
for (var i = 0; i < s.length; i++) {
var pos = s.length - i;
out = s[i] + out;
if (pos > 1 && (pos - 1) % 3 === 0) out += ',';
}
return out;
}
var SENT_PREFIX = 'Total ', SENT_MID1 = ' split among ', SENT_MID2 = ' people gives ', SENT_MID3 = ' per person, which is ', SENT_SUFFIX = '% of the total.';
var targetF = 0, renderedF = 0;
var LERP_RATE = 0.15;
function applyValue(total, n) {
var share = perPersonShare(total, n);
var frac = shareFraction(n);
targetF = frac;
shareEl.textContent = '1/' + n;
readout.textContent = SENT_PREFIX + formatNumber(total) + SENT_MID1 + n + SENT_MID2 + formatNumber(share) + SENT_MID3 + (frac * 100).toFixed(1) + SENT_SUFFIX;
if (reduce) { renderedF = targetF; paint(); }
}
function paint() {
var offset = CIRCUMFERENCE * (1 - renderedF);
fill.style.strokeDashoffset = offset.toFixed(2);
}
function readValues() {
var total = parseFloat(totalInput.value); if (isNaN(total) || total < 0) total = 0;
var n = parseInt(countInput.value, 10); if (isNaN(n) || n < 1) n = 1;
return { total: total, n: n };
}
function onInput() { human(); var v = readValues(); applyValue(v.total, v.n); }
totalInput.addEventListener('input', onInput);
totalInput.addEventListener('change', onInput);
countInput.addEventListener('input', onInput);
countInput.addEventListener('change', onInput);
var DEMO_VALUES = [
{ total: 42000, n: 3 },
{ total: 15000, n: 2 },
{ total: 88000, n: 5 },
{ total: 120000, n: 4 },
{ total: 9600, n: 6 },
{ total: 250000, n: 8 }
];
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_VALUES.length);
if (idx !== lastIdx) {
lastIdx = idx;
totalInput.value = DEMO_VALUES[idx].total;
countInput.value = DEMO_VALUES[idx].n;
applyValue(DEMO_VALUES[idx].total, DEMO_VALUES[idx].n);
}
}
requestAnimationFrame(driveTick);
}
function frame() {
renderedF += (targetF - renderedF) * LERP_RATE;
paint();
requestAnimationFrame(frame);
}
var init = readValues();
applyValue(init.total, init.n);
if (!reduce) {
requestAnimationFrame(driveTick);
requestAnimationFrame(frame);
}
})();import { useEffect, useRef } from "react";
import styles from "./TipSplitRing.module.scss";
export default function TipSplitRing() {
const rootRef = useRef<HTMLDivElement | null>(null);
const totalRef = useRef<HTMLInputElement | null>(null);
const countRef = useRef<HTMLInputElement | null>(null);
const fillRef = useRef<SVGCircleElement | null>(null);
const shareRef = useRef<HTMLSpanElement | null>(null);
const readoutRef = useRef<HTMLParagraphElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const totalInput = totalRef.current;
const countInput = countRef.current;
const fill = fillRef.current;
const shareEl = shareRef.current;
const readout = readoutRef.current;
if (!root || !totalInput || !countInput || !fill || !shareEl || !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 perPersonShare(total: number, n: number) {
return n > 0 ? total / n : 0;
}
function shareFraction(n: number) {
return n > 0 ? 1 / n : 0;
}
function formatNumber(v: number) {
const s = Math.round(v).toString();
let out = "";
for (let i = 0; i < s.length; i++) {
const pos = s.length - i;
out = s[i] + out;
if (pos > 1 && (pos - 1) % 3 === 0) out += ",";
}
return out;
}
const SENT_PREFIX = "총액 ";
const SENT_MID1 = "원을 ";
const SENT_MID2 = "명이 나누면 1인당 ";
const SENT_MID3 = "원이고 전체의 ";
const SENT_SUFFIX = "%입니다";
let targetF = 0;
let renderedF = 0;
const LERP_RATE = 0.15;
function applyValue(total: number, n: number) {
const share = perPersonShare(total, n);
const frac = shareFraction(n);
targetF = frac;
shareEl!.textContent = "1/" + n;
readout!.textContent =
SENT_PREFIX + formatNumber(total) + SENT_MID1 + n + SENT_MID2 + formatNumber(share) + SENT_MID3 + (frac * 100).toFixed(1) + SENT_SUFFIX;
if (reduce) {
renderedF = targetF;
paint();
}
}
function paint() {
const offset = CIRCUMFERENCE * (1 - renderedF);
fill!.style.strokeDashoffset = offset.toFixed(2);
}
function readValues() {
let total = parseFloat(totalInput!.value); if (isNaN(total) || total < 0) total = 0;
let n = parseInt(countInput!.value, 10); if (isNaN(n) || n < 1) n = 1;
return { total, n };
}
function onInput() {
human();
const v = readValues();
applyValue(v.total, v.n);
}
totalInput.addEventListener("input", onInput);
totalInput.addEventListener("change", onInput);
countInput.addEventListener("input", onInput);
countInput.addEventListener("change", onInput);
const DEMO_VALUES = [
{ total: 42000, n: 3 },
{ total: 15000, n: 2 },
{ total: 88000, n: 5 },
{ total: 120000, n: 4 },
{ total: 9600, n: 6 },
{ total: 250000, n: 8 },
];
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_VALUES.length);
if (idx !== lastIdx) {
lastIdx = idx;
totalInput!.value = String(DEMO_VALUES[idx].total);
countInput!.value = String(DEMO_VALUES[idx].n);
applyValue(DEMO_VALUES[idx].total, DEMO_VALUES[idx].n);
}
}
rafDrive = requestAnimationFrame(driveTick);
}
let rafFrame = 0;
function frame() {
renderedF += (targetF - renderedF) * LERP_RATE;
paint();
rafFrame = requestAnimationFrame(frame);
}
const init = readValues();
applyValue(init.total, init.n);
if (!reduce) {
rafDrive = requestAnimationFrame(driveTick);
rafFrame = requestAnimationFrame(frame);
}
return () => {
totalInput.removeEventListener("input", onInput);
totalInput.removeEventListener("change", onInput);
countInput.removeEventListener("input", onInput);
countInput.removeEventListener("change", onInput);
if (rafDrive) cancelAnimationFrame(rafDrive);
if (rafFrame) cancelAnimationFrame(rafFrame);
};
}, []);
return (
<div ref={rootRef} className={`${styles.root} is-demo`}>
<div className={styles.fields}>
<div className={styles.field}>
<span className={styles.cap}>총액</span>
<input
ref={totalRef}
className={styles.total}
type="number"
inputMode="numeric"
min={0}
step={1000}
defaultValue={42000}
aria-label="총액 값을 입력하면 인원 값과 함께 1인당 분담액을 계산해 링 조각을 채웁니다"
aria-describedby="tsReadoutReact"
/>
</div>
<div className={styles.field}>
<span className={styles.cap}>인원</span>
<input
ref={countRef}
className={styles.count}
type="number"
inputMode="numeric"
min={1}
max={12}
step={1}
defaultValue={3}
aria-label="인원 값을 입력하면 총액과 함께 1인당 분담액을 계산해 링 조각을 채웁니다"
aria-describedby="tsReadoutReact"
/>
</div>
</div>
<div className={styles.ring}>
<svg viewBox="0 0 72 72" aria-hidden="true">
<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={shareRef} className={styles.share} aria-hidden="true" />
</div>
<p ref={readoutRef} className={styles.readout} id="tsReadoutReact" role="status" aria-live="polite" />
</div>
);
}08 Tip-split Ring That Divides a Share From a Headcount Value — Measured area 3.385% · 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)
Inputs09 Focus ring that walks you to the first error9 Form Validation UI Patterns — As You Type
Inputs03 Limit bounce — a stepper value that overshoots its min or max slightly before settling along a spring curveNumber Stepper UI: 9 Interaction Patterns