Roll Value
A value that rolls to its new text instead of swapping: up for a later one, down for an earlier one.
$npx shadcn@latest add https://framebrick.co/r/roll-value.jsonFrom the Metuo template, see it in context.
$129/month
"use client";
import * as React from "react";
import { RollValue } from "@/components/ui/roll-value";
import { SegmentedControl } from "@/components/ui/segmented-control";
/* The billing period changes and the price rolls to the other one: up for a later value, down for an earlier one. */
export default function RollValueDemo() {
const [period, setPeriod] = React.useState("monthly");
const index = period === "yearly" ? 1 : 0;
return (
<div className="flex flex-col items-center gap-6">
<SegmentedControl
label="Billing period"
size="sm"
options={[
{ value: "monthly", label: "Monthly" },
{ value: "yearly", label: "Yearly" },
]}
value={period}
onValueChange={setPeriod}
/>
<p className="flex items-baseline gap-1.5 text-5xl font-semibold tracking-tight">
<RollValue items={["$129", "$1,049"]} index={index} className="text-right" />
<RollValue items={["/month", "/year"]} index={index} className="text-base font-normal text-muted-foreground" />
</p>
</div>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/roll-value.json1. Install the dependencies.
npm install motion clsx tailwind-merge2. Add
cn()tolib/utils.ts(skip if shadcn already created it).import { clsx, type ClassValue } from "clsx"; import { extendTailwindMerge } from "tailwind-merge"; /* tailwind-merge must know the site's type scale (globals.css) is a font size, not a text color — otherwise cn("text-h6 text-muted-foreground") drops text-h6. */ const twMerge = extendTailwindMerge({ extend: { classGroups: { "font-size": [{ text: ["display", "h1", "h2", "h3", "h4", "h5", "h6"] }] } }, }); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into
components/ui/roll-value.tsx.import * as React from "react"; import { cn } from "@/lib/utils"; /* * Roll Value — a value that rolls to its new text instead of swapping (from the Metuo pricing: the price sits in a window one * line tall over a column holding both periods, "$129" over "$1,049", and the billing switch rolls the column up one line, * 69px for the 32px price, "/month" turning into "/year" beside it). * * Every value it can show is in the page, one above the other, in a window one line tall (1lh, the line height it inherits); the * column moves so that the one at `index` is in the window. Going to a later value rolls up, going back rolls down. Framer's * spring (bounce 0, 0.4s), as in Metuo's S0, as a CSS linear() curve sampled from the spring itself (tools/ui/spring-curve.mjs in * the Framebrick workspace; within 1% of it); a browser without linear() gets cubic-bezier(0.22, 1, 0.36, 1). It needs the lh * unit: Chrome 109, Safari 16.4 or Firefox 120. * * It is plain CSS: no animation library, no JavaScript, so it works as it is on a server page, and the right value is in the * server's HTML. It is as wide as the widest value, so what is beside it does not move; use text-right or text-center to align * the narrower ones. Only the value at `index` is read to assistive technology (the others are hidden and inert), and nothing is * announced when it changes: the control that changes it says what it did, or give this an aria-live of its own. * With reduced motion it changes at once. */ const ease = "[--roll-ease:cubic-bezier(0.22,1,0.36,1)] supports-[transition-timing-function:linear(0,1)]:[--roll-ease:linear(0,0.041,0.134,0.248,0.364,0.472,0.569,0.652,0.722,0.78,0.827,0.864,0.894,0.918,0.937,0.951,0.963,0.971,0.978,0.983,0.987,0.99,0.993,0.995,0.996,0.997,0.998,0.998,0.999,1)]"; export type RollValueProps = Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> & { /** Every value it can show, in order. */ items: React.ReactNode[]; /** Which of them shows now. */ index: number; /** Space between two values in the column, in px. It only shows during a roll. */ gap?: number; }; export function RollValue({ items, index, gap = 10, className, style, ...props }: RollValueProps) { const current = Math.min(Math.max(Math.round(index), 0), Math.max(items.length - 1, 0)); return ( <span {...props} style={{ ["--i" as string]: current, ["--gap" as string]: `${gap}px`, ...style }} className={cn(ease, "relative inline-block h-[1lh] overflow-hidden align-bottom", className)} > <span className="flex flex-col gap-(--gap) transition-transform duration-400 ease-(--roll-ease) motion-reduce:transition-none" style={{ transform: "translateY(calc(var(--i) * -1 * (1lh + var(--gap))))" }} > {items.map((item, i) => ( <span key={i} aria-hidden={i !== current} inert={i !== current} className="block h-[1lh] whitespace-nowrap"> {item} </span> ))} </span> </span> ); }
Usage
import { RollValue } from "@/components/ui/roll-value";
// every value it can show, and which one shows now
<p className="text-5xl font-semibold">
<RollValue items={["$129", "$1,049"]} index={yearly ? 1 : 0} />
</p>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | ReactNode[] | — | Every value it can show, in order. They are all in the page, one above the other. |
| index | number | — | Which of them shows now. A later one than before rolls up, an earlier one rolls down. |
| gap | number | 10 | Space between two values in the column, in px. It only shows while it rolls. |
| className | string | — | Classes for the window: the text size and weight go here, or text-right to align the narrower values. |
- It is Metuo's price roll: the price sits in a window one line tall over a column holding both periods, and the billing switch rolls the column up one line (69 px for a 32 px price) with Framer's spring, bounce 0, 0.4 s; "/month" turns into "/year" beside it the same way. Going back rolls down.
- It sizes itself from the text: the window is one line tall (1lh, the line height it inherits) and as wide as the widest value, so what is beside it never moves. It needs the lh unit: Chrome 109, Safari 16.4 or Firefox 120.
- Plain CSS: no animation library and no JavaScript, so it works as it is on a server page, and the right value is in the server's HTML. The spring is a CSS linear() curve sampled from the spring itself, within 1% of it; a browser without linear() gets cubic-bezier(0.22, 1, 0.36, 1). With reduced motion it changes at once.
- Only the value at index is read to assistive technology (the others are aria-hidden and inert), and nothing is announced when it changes: the control that changed it says what it did, or give this an aria-live of its own.
- It is not Text Rotator, which cycles through its texts by itself on a timer; this one goes where it is told.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this component in your own project.
Create a React + Tailwind CSS component called RollValue — plain CSS, no JavaScript and no animation library, so it works in a server component. - Props: items (ReactNode[], every value it can show), index (which one shows), gap (px, 10), className and the span attributes. Clamp the index to the items. - A span (relative inline-block h-[1lh] overflow-hidden align-bottom) with --i (the index) and --gap set inline, holding a flex flex-col column (gap-(--gap)) with one block h-[1lh] whitespace-nowrap span per item; the items that are not at the index are aria-hidden and inert. - The column's transform is translateY(calc(var(--i) * -1 * (1lh + var(--gap)))) with transition-transform over 400ms and an easing from the custom property --roll-ease: cubic-bezier(0.22,1,0.36,1), replaced by a 30-stop linear() list sampled from a spring with bounce 0 and duration 0.4s inside supports-[transition-timing-function:linear(0,1)]; motion-reduce:transition-none.











