Skip to content
TuffTasks, docs and chat for teams and AI agents

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.

1 variantMIT

Preview

Renewals

Next 12 months · from today

5 in 90 d

Term left

7 of 11 shown

KestrelNorthvaleHaldenOrreryBrightwellCobalt BayMarlow

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.json

Usage

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

PropTypeDefaultDescription
contracts*RenewalContract[]—Every contract, in any order. Those ending within the horizon are sorted by end date; the soonest gets the outer ring.
todaystring | Datenew 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.
horizonMonthsnumber12Months around the clock face. Contracts ending later are left out.
windowDaysnumber90Renewal window: counted in the header pill and the center, marked by the dotted radius, and the scope of the auto-renew and ACV stats.
noticeSoonDaysnumber30Notice deadlines within this many days count as due in the footer.
maxRingsnumber6Rings drawn at most; the soonest ends are kept and the panel says "6 of 9 shown". Rings tighten to fit.
selectedIdstring | null—Controlled selection. The selected ring stays highlighted and its details stay open; hover and focus preview others.
defaultSelectedIdstring | nullnullStarting 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.
actionsRenewalAction[]—Small follow-up buttons under the active contract's details, e.g. Draft notice or Remind me. `hidden` drops one for some contracts.
titlestring"Renewals"Header title.
subtitlestring"Next 12 months · from today"Secondary line under the title.
headingstring"Term left"Heading above the chart, also its accessible name.
detailsbooleantrueShows the details block: below the chart on narrow cards, beside it from 32rem.
statsbooleantrueShows the footer: notices due, auto-renewals and ACV up for renewal.
introbooleantrueSweeps 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.
classNamestring—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;
};