Skip to content
txbrendan/ui

Agreement

beta

Consent and codes of conduct: an "I agree" button that records who and when, never a checkbox. Once agreed, the record shows in its place with the person's photo.

Preview

stack

Code of conduct

Treat everyone with respect, keep shared spaces tidy, and report problems early.
Read the code of conduct

Agreed by Anna Price ·

First aid consent

Staff may give first aid and call for help if Mia is hurt at an event.

ForMia Price

Photo consent

The team may share event photos with Mia in them. You can take this back at any time.

ForMia Price

main

Terms of service

Invoices are due on the first day of each month. You can cancel at any time.
Read the terms of service

size

Code of conduct, sm

Treat everyone with respect.

Code of conduct, md

Treat everyone with respect.

Code of conduct, lg

Treat everyone with respect.

states

Agreed

Agreed by Anna Price ·

Saving

The button waits while the record is saved.

Disabled

Only a guardian can agree for a junior.

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. Add the component.

    npx shadcn@latest add @txbrendan/agreement

Use it for

  • Codes of conduct, consents, and terms that someone must agree to, by name.
  • Store the record in onAgree and pass it back as agreed: { person, photo, at }. The record showing in place is the confirmation, so no toast repeats it.
  • A guardian agreeing for a junior passes forPerson. The record then says who it was for.
  • A consent that can be taken back, like photo consent, passes onWithdraw.
  • A required one left unagreed takes error, set when the form is sent. Its title and button edge turn red, its button glows once, and nothing changes size. Say what is missing on the button that was pressed, with shake() and new words, so it never moves.
  • In a stack, each sits on the sheet with no box, divided by a faint line. Pass divider={false} on the first one. The sheet's own Continue is the main action, so the buttons stay outlines.
  • When it is the surface's only decision, pass main, and the button is solid brand.

Avoid it for

  • Settings that are just on or off. Use a switch.
  • Picking among options. Use choice chips or a radio group.
  • A checkbox for consent. A button that records who and when says more, and cannot be ticked by accident.

Props

  • title

    What is agreed to, like "Code of conduct". It names the agreement.

    ReactNode

  • text

    The short version, in plain words.

    ReactNode

  • link

    The full text: a link with href, or a button with onClick.

    { label: ReactNode; href?: string; onClick?: () => void }

  • forPerson

    Who it is for, when someone agrees on their behalf.

    { person: string; photo?: string }

  • agreed

    The record. Once set, it shows in place of the button: "Agreed by Anna Price · Tue 29 Sept, 18:04".

    { person: string; photo?: string; at?: string | Date } | null

  • onAgree

    Store the record here, then pass it back as agreed.

    () => void

  • onWithdraw

    Shows Withdraw on the record. Clear agreed here.

    () => void

  • error

    For a required one left unagreed. The title and button edge turn red and the button glows once, with no change in size. The words describe the button for screen readers.

    ReactNode

  • divider

    A faint line above it that fades out at both ends. Pass false on the first of a stack.

    boolean · default true

  • agreeLabel

    The button's words.

    ReactNode · default "I agree"

  • main

    The surface's only decision: the button is solid brand. Off, it is an outline.

    boolean · default false

  • size

    Text, button, and photo size.

    "sm" | "md" | "lg" · default "md"

  • loading

    The button shows a spinner while the record is saved.

    boolean · default false

  • disabled

    Blocks the button and Withdraw.

    boolean · default false

  • locale

    Formats the record's date and time.

    string · default "en-GB"

  • timeZone

    The zone the record's time shows in, like "Europe/London". By default, wherever it renders. Pass one when a server renders the page.

    string

  • className

    Classes for the root.

    string

Accessibility

  • The root is a group named by the title, so each "I agree" in a stack is heard with what it agrees to.
  • An error is described on the button. It is not announced here: the press that found it says it out loud, like a role="alert" beside the button that was pressed.
  • The glow follows the --motion-* tokens, so reduced motion skips it. The red title and edge stay.
  • Once agreed, focus moves from the button to the record, so screen readers read who agreed and when. After Withdraw it moves back to the button.
  • The record's date is a <time> element with a machine-readable dateTime.
  • The faint divider is hidden from screen readers. In forced-colors mode it draws as a plain border.

Examples

In a stack

<div className="flex flex-col gap-4">
  <Agreement
    divider={false}
    title="Code of conduct"
    text="Treat everyone with respect, keep shared spaces tidy, and report problems early."
    link={{ label: "Read the code of conduct", href: "/conduct" }}
    agreed={{ person: "Anna Price", at: "2026-09-29T18:04" }}
  />
  <Agreement
    title="Photo consent"
    forPerson={{ person: "Mia Price" }}
    error="The club needs photo consent to share team photos."
  />
</div>

The only decision

<Agreement main title="Terms of service" text="Invoices are due on the first day of each month." />