Allocation ring
A sculpted portfolio ring redistributes across years while a selected holding keeps its identity.

Choose a year; hover, focus or select a holding.
Subframe Original · MIT
Add to your project
Component source
Standalone React component. Styles are included.
React only. No additional packages.
Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.
View source code
/**
* MIT License
* Copyright (c) 2026 Subframe
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
"use client"
import * as React from "react"
const HOLDINGS = [
{ label: "US equities", light: "#e6f5cf", color: "#acdf79", dark: "#47772f" },
{ label: "International", light: "#fff5da", color: "#d3b881", dark: "#6b5030" },
{ label: "Bonds", light: "#dfe8f1", color: "#8fa6ba", dark: "#344958" },
{ label: "Cash", light: "#b9bbb6", color: "#636760", dark: "#292e28" },
]
const PERIODS = [
{ label: "2024", values: [32480, 20160, 13440, 1120] },
{ label: "2025", values: [38610, 21735, 13560, 1595] },
{ label: "2026", values: [47180, 21063, 13480, 2527] },
]
const money = (value: number) =>
value.toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 })
export default function Example({ active = true }: { active?: boolean }) {
const uid = React.useId().replace(/:/g, "")
const [period, setPeriod] = React.useState(2)
const [selected, setSelected] = React.useState<number | null>(null)
const [hovered, setHovered] = React.useState<number | null>(null)
const [keyboard, setKeyboard] = React.useState(false)
const values = PERIODS[period].values
const total = values.reduce((a, b) => a + b, 0)
const current = hovered ?? selected
let offset = 0
const segments = values.map((value) => {
const share = (value / total) * 100
const part = { share, offset }
offset += share
return part
})
React.useEffect(() => {
if (!active) setHovered(null)
}, [active])
return (
<section
className="sf-allocation-ring"
data-still={!active || keyboard}
onPointerDownCapture={() => setKeyboard(false)}
onKeyDownCapture={(e) => {
setKeyboard(true)
if (e.key === "Escape") {
setSelected(null)
setHovered(null)
}
}}
aria-label="Investment allocation"
>
<div className="portfolio">
<div className="ring-wrap">
<svg
viewBox="0 0 260 260"
role="img"
aria-label={
current === null
? "Portfolio allocation ring"
: `${HOLDINGS[current].label}: ${Math.round((values[current] / total) * 100)} percent`
}
>
<defs>
{HOLDINGS.map((part, i) => (
<linearGradient
key={part.label}
id={`${uid}-g${i}`}
x1="0"
y1="0"
x2=".8"
y2="1"
gradientUnits="objectBoundingBox"
>
<stop stopColor={part.light} />
<stop offset=".36" stopColor={part.color} />
<stop offset="1" stopColor={part.dark} />
</linearGradient>
))}
</defs>
{Array.from({ length: 64 }, (_, i) => (
<path key={i} d="M130 4v3" transform={`rotate(${i * 5.625} 130 130)`} stroke="#51544c" strokeWidth=".7" />
))}
<circle cx="130" cy="130" r="97" stroke="#1b2019" strokeWidth="32" fill="none" />
{segments.map((part, i) => (
<g
key={HOLDINGS[i].label}
className="arc"
style={{ opacity: current !== null && current !== i ? 0.22 : 1 }}
onPointerEnter={(e) => {
if (active && e.pointerType === "mouse") setHovered(i)
}}
onPointerLeave={() => setHovered(null)}
onClick={() => {
if (active) setSelected(selected === i ? null : i)
}}
>
<circle
cx="130"
cy="130"
r="97"
pathLength="100"
fill="none"
stroke={`url(#${uid}-g${i})`}
strokeWidth={current === i ? 36 : 30}
transform="rotate(-90 130 130)"
style={{
strokeDasharray: `${Math.max(0, part.share - 1.3)} 100`,
strokeDashoffset: -part.offset - 0.65,
}}
/>
<circle
className="glint"
cx="130"
cy="130"
r="109.5"
pathLength="100"
fill="none"
stroke={HOLDINGS[i].light}
strokeWidth=".65"
transform="rotate(-90 130 130)"
style={{
strokeDasharray: `${Math.max(0, part.share - 1.7)} 100`,
strokeDashoffset: -part.offset - 0.85,
}}
/>
</g>
))}
</svg>
<div className="ring-readout" aria-hidden>
<span>{current === null ? PERIODS[period].label : HOLDINGS[current].label}</span>
<strong>{money(current === null ? total : values[current])}</strong>
<span>
{current === null ? "Portfolio balance" : `${Math.round((values[current] / total) * 100)}% of portfolio`}
</span>
</div>
</div>
<div className="holdings">
<header>
<strong>Asset allocation</strong>
</header>
<div className="periods" aria-label="Allocation year">
{PERIODS.map((item, i) => (
<button
type="button"
key={item.label}
disabled={!active}
aria-pressed={i === period}
onClick={() => setPeriod(i)}
>
{item.label}
</button>
))}
</div>
<div className="legend">
{HOLDINGS.map((part, i) => (
<button
type="button"
key={part.label}
disabled={!active}
aria-pressed={selected === i}
aria-label={`${part.label}, ${money(values[i])}, ${Math.round((values[i] / total) * 100)} percent`}
onPointerEnter={(e) => {
if (active && e.pointerType === "mouse") setHovered(i)
}}
onPointerLeave={() => setHovered(null)}
onFocus={(event) => {
if (event.currentTarget.matches(":focus-visible")) setHovered(i)
}}
onBlur={() => setHovered(null)}
onClick={() => setSelected(selected === i ? null : i)}
>
<i style={{ background: part.color }} />
<span>{part.label}</span>
<b>
{Math.round((values[i] / total) * 100)}
<small>%</small>
</b>
</button>
))}
</div>
</div>
</div>
<div className="sr" role="status">
{PERIODS[period].label}: {money(total)}.{" "}
{selected !== null ? `${HOLDINGS[selected].label}, ${money(values[selected])}.` : ""}
</div>
<style>{`
.sf-allocation-ring{width:100%;height:100%;display:grid;place-items:center;background:#0c100c;color:#eff0e8;font:13px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;overflow:hidden;-webkit-font-smoothing:antialiased}.sf-allocation-ring *{box-sizing:border-box}.sf-allocation-ring button{font:inherit;color:inherit;cursor:pointer;touch-action:manipulation}.sf-allocation-ring button:disabled{cursor:default}.sf-allocation-ring :focus-visible{outline:2px solid #c2eba3;outline-offset:3px}.sf-allocation-ring .portfolio{display:grid;grid-template-columns:274px 212px;gap:25px;align-items:center}.sf-allocation-ring .ring-wrap{position:relative;width:274px;height:274px}.sf-allocation-ring svg{width:100%;height:100%;overflow:visible}.sf-allocation-ring .arc{cursor:pointer;transition:opacity 140ms ease}.sf-allocation-ring .arc circle{transition:stroke-dasharray 460ms cubic-bezier(.22,1,.36,1),stroke-dashoffset 460ms cubic-bezier(.22,1,.36,1),stroke-width 160ms ease}.sf-allocation-ring .glint{opacity:.65;pointer-events:none}.sf-allocation-ring .ring-readout{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;pointer-events:none;gap:5px}.sf-allocation-ring .ring-readout>span{font-size:10px;color:#93998c}.sf-allocation-ring .ring-readout strong{font-size:31px;line-height:1.1;font-weight:450;letter-spacing:-1.2px;font-variant-numeric:tabular-nums}.sf-allocation-ring .ring-readout small{font-size:23px;letter-spacing:-.5px;margin-left:2px;color:#b2b7a9}.sf-allocation-ring header>span{color:#b3b9aa;font-size:12px}.sf-allocation-ring header>strong{display:block;font-size:21px;letter-spacing:-.5px;font-weight:480;white-space:nowrap;margin-top:3px;font-variant-numeric:tabular-nums}.sf-allocation-ring header small{font-size:23px;color:#798270;font-weight:400}.sf-allocation-ring .periods{display:flex;gap:8px;margin:12px 0 9px}.sf-allocation-ring .periods button{height:32px;position:relative;padding:0 10px;border:0;border-radius:20px;font-size:10px;background:none;color:#828a79}.sf-allocation-ring .periods button:after{content:"";position:absolute;inset:-6px 0}.sf-allocation-ring .periods button[aria-pressed=true]{background:#dee9ce;color:#263320;box-shadow:inset 0 1px 0 #fff8}.sf-allocation-ring .legend button{height:42px;padding:0 2px;display:flex;align-items:center;gap:8px;width:100%;background:none;border:0;border-bottom:1px solid #ffffff0c;font-size:11px;text-align:left}.sf-allocation-ring .legend button:last-child{border-bottom:0}.sf-allocation-ring .legend i{width:7px;height:7px;border-radius:50%}.sf-allocation-ring .legend b{margin-left:auto;font-size:12px;font-weight:500;font-variant-numeric:tabular-nums}.sf-allocation-ring .legend small{font-size:10px;font-weight:400;color:#8f9885}.sf-allocation-ring .legend button[aria-pressed=true] span{color:#c1e59e}.sf-allocation-ring .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}.sf-allocation-ring[data-still=true] *{transition:none!important}@media(prefers-reduced-motion:reduce){.sf-allocation-ring *{transition:none!important}}`}</style>
</section>
)
}
Where it works well
Inspect how an investment portfolio is divided, then compare the same holding across datasets.
Reduced motion
Finite transitions become immediate under reduced motion and keyboard input. Host pause disables controls; any owned drag is cancelled.
Things to know
Fictional portfolio data; no market feed, performance projection or financial calculation beyond sums and shares. Four SVG segments, no chart runtime.