Interactive Answers
Renewal Rings
Contract renewals on a 12-month clock — one ring per contract from today to its end date, with the notice window hatched and the next deadline picked out.
Preview
Renewals
Next 12 months · from today
Term left
7 of 11 shown
Point at a ring, or tab to it, to see the contract.
Term left: 11 contracts end in the next 12 months, 5 within 90 days. Kestrel: ends Nov 20, 2026, 16 days left, $84K ACV, auto-renews, notice Oct 21, 2026, passed. Northvale: ends Dec 8, 2026, 34 days left, $280K ACV, auto-renews, notice Nov 8, 2026, in 4 d. Halden: ends Dec 26, 2026, 52 days left, $126K ACV, auto-renews, notice Nov 26, 2026, in 22 d. Orrery: ends Jan 14, 2027, 71 days left, $480K ACV, does not auto-renew, notice Nov 15, 2026, in 11 d. Brightwell: ends Jan 31, 2027, 88 days left, $58K ACV, auto-renews, notice Jan 1, 2027, in 58 d. Cobalt Bay: ends Apr 3, 2027, 150 days left, $190K ACV, auto-renews, notice Jan 3, 2027, in 60 d. Marlow: ends Jun 27, 2027, 235 days left, $72K ACV, does not auto-renew, notice Apr 28, 2027, in 175 d.
- Notice due
- 3in 30 d
- Auto-renew
- 4of 5
- Up for renewal
- $1MACV
Installation
Copies the component into components/ui and installs its dependencies and theme tokens.
pnpm dlx shadcn@latest add https://tuffui.com/r/renewal-rings.jsonUsage
Pass your own data and wire the callbacks to your agent runtime.
import { BellSimple, PencilSimpleLine } from "@phosphor-icons/react";
import { RenewalRings } from "@/components/ui/renewal-rings";
// Rendered by the agent in answer to "which contracts renew soon?"
export function RenewalsAnswer({ contracts, firstDeadline }: Props) {
return (
<RenewalRings
today={new Date()}
contracts={contracts.map((c) => ({
id: c.id,
name: c.account, // "Northvale"
endDate: c.termEnd, // "2026-12-08"
noticeDays: c.noticePeriod, // 30
value: c.acv,
autoRenew: c.autoRenew,
note: c.id === firstDeadline.id ? firstDeadline.summary : undefined,
}))}
// The agent points at the first notice deadline.
defaultSelectedId={firstDeadline.id}
actions={[
{
label: "Draft notice",
icon: PencilSimpleLine,
onSelect: (c) => sendFollowUp(`Draft a non-renewal notice for ${c.name}`),
},
{ label: "Remind me", icon: BellSimple, onSelect: (c) => remind(c.id) },
]}
/>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| contracts* | RenewalContract[] | — | Every contract, in any order. Those ending within the horizon are sorted by end date; the soonest gets the outer ring. |
| today | string | Date | new Date() | Start of the clock face, at 12 o'clock. Dates are compared as calendar days. Pass a fixed date (e.g. `"2026-11-04"`) when rendering on the server. |
| horizonMonths | number | 12 | Months around the clock face. Contracts ending later are left out. |
| windowDays | number | 90 | Renewal window: counted in the header pill and the center, marked by the dotted radius, and the scope of the auto-renew and ACV stats. |
| noticeSoonDays | number | 30 | Notice deadlines within this many days count as due in the footer. |
| maxRings | number | 6 | Rings drawn at most; the soonest ends are kept and the panel says "6 of 9 shown". Rings tighten to fit. |
| selectedId | string | null | — | Controlled selection. The selected ring stays highlighted and its details stay open; hover and focus preview others. |
| defaultSelectedId | string | null | null | Starting selection when uncontrolled. |
| onSelectedChange | (id: string | null) => void | — | Called when a ring or its name is clicked, or picked with Enter or Space. Clicking the selected ring clears it; so does Escape. Arrow keys, Home and End move between rings. |
| actions | RenewalAction[] | — | Small follow-up buttons under the active contract's details, e.g. Draft notice or Remind me. `hidden` drops one for some contracts. |
| title | string | "Renewals" | Header title. |
| subtitle | string | "Next 12 months · from today" | Secondary line under the title. |
| heading | string | "Term left" | Heading above the chart, also its accessible name. |
| details | boolean | true | Shows the details block: below the chart on narrow cards, beside it from 32rem. |
| stats | boolean | true | Shows the footer: notices due, auto-renewals and ACV up for renewal. |
| intro | boolean | true | Sweeps each arc in from Today on mount, one after another. Off renders the finished chart. Skipped with reduced motion. |
| formatValue | (value: number) => string | "$1.2M" | Formats ACV in the details and the footer. |
| formatDate | (date: Date) => string | "Mar 14, 2027" | Formats end and notice dates. The date is UTC midnight of that day. |
| formatMonth | (date: Date) => string | "M" | Month labels around the face. |
| className | string | — | Extra classes for the root element. |
type RenewalContract = {
id: string;
name: string; // "Northvale"
endDate: string | Date; // last day of the term, "2026-12-08"
noticeDays?: number; // hatched window before the end; 0 hides it
value: number; // annual contract value
autoRenew?: boolean;
note?: React.ReactNode; // shown with the details, e.g. the agent's note
};
type RenewalAction = {
label: string;
icon?: Icon; // Phosphor icon
onSelect: (contract: RenewalContract) => void;
hidden?: (contract: RenewalContract) => boolean;
};