Skip to content
Join UI
Text Effectsnew

Carbon Copy

A headline that is set, then copied: the first line inks up, and each line after it peels off the last with the changed words turning over.

View sourceon GitHub (opens in a new tab)Open full preview

CarbonCopy is the sentence a product opens with, made with the type itself. The first line is set as a hairline and inked — the weight of the letters rises from the thinnest cut of the face to the one the line rests at, so the words are seen to fill rather than to appear. Then a copy is taken: the next line starts exactly on top of the first, identical to it, and travels down one line to its place, lifting a little on the way and seating with a spring. While it travels, the words that differ turn over in windows — the old word rolls down and out, the new one drops in from above, the way a counter turns — so the copy lands already reading as the next line, and a third line is a copy of the second. It is built for the headline whose second line is a revision of its first: copy, keep; build fast, build right. The movement is the meaning, which is what keeps it from being an effect. Every window is measured from the rendered glyphs, so the copy lies on the original to the pixel in any face at any size, and the finished line is set the way it would have been set on its own. It plays once, when it is scrolled into view or when it mounts, and hands you a callback on its last seat so the rest of the section can be cued from it rather than from a timer.

Preview

Carbon Copy
The line, and a copy of it

The first line inks up from a hairline. Then a copy is taken: the second line peels off the first, travels down a line and seats with a spring, and on the way its first word turns over in a window — the old word rolls out below, the new one drops in from above. It plays when it comes into view.

Copy the code.Keep the code.

Three lines

Each line is a copy of the one before, so a third line is taken from the second once it has seated. Only the words that differ turn over; the rest of the line comes down unchanged.

Build it fast.Build it right.Build it once.

ink={false}, align

Without the ink the first line is simply there, and the copy is the whole event. Set from the left edge, at body size. These two lines have different numbers of words, so the copy turns over as a whole rather than word by word.

Deploys in seconds.Rolls back in one click.

trigger="mount", timing, onComplete

Played the moment it mounts, on a slower clock, and what comes after the headline is cued from its last seat rather than from a timer of its own — the sentence and the call to action are set down only once the copy has landed.

Ship on Friday.Sleep on Friday.

Installation

Install Carbon Copy with the shadcn CLI. The registry item resolves its own dependencies and writes the file to components/joinui/carbon-copy.tsx.

pnpm dlx shadcn@latest add @joinui/carbon-copy

Usage

example.tsx
import { CarbonCopy } from "@/components/joinui/carbon-copy"

export function Example() {
  return (
    <CarbonCopy
      lines={["Copy the code.", "Keep the code."]}
      className="text-[clamp(2.5rem,6vw,5rem)] leading-none tracking-[-0.042em]"
    />
  )
}

Props

CarbonCopy

Props for CarbonCopy
PropTypeDefaultDescription
lines (required)readonly string[]—The lines, in the order they are made. The first is set; each one after it is a copy of the one before, with the words that differ turned over. Lines with the same number of words turn over word by word; lines with different counts turn over as a whole.
as"h1" | "h2" | "h3" | "h4" | "p" | "div""h1"The element the lines are set in.
align"start" | "center" | "end""center"Which edge the lines hang from. A copy lies on its original whichever it is.
inkbooleantrueWhether the first line inks up from a hairline. Off, the first line is simply there and the copy is the whole event — the right choice on a static face, which can only step between the weights it has.
inkFromnumber100The weight the ink starts from.
weightnumber600The weight the lines rest at, and the ink ends at. Set as the element's font-weight, so a className need not repeat it.
trigger"view" | "mount""view"Play when three fifths of the element has been scrolled into view, or as soon as it mounts. Either way it plays once.
timingPartial<CarbonCopyTiming>—Overrides for the tempo, in milliseconds. Anything you leave out keeps its default.
onComplete() => void—Fires once the last copy has seated — or at once, under reduced motion — so what follows the headline can be cued from it.
classNamestring—Merged onto the element through `cn`. Size, leading and tracking are yours to set; the component sets only the weight.

CarbonCopyTiming

Shape of the `timing` prop. All values are milliseconds.

Props for CarbonCopyTiming
PropTypeDefaultDescription
delaynumber0Before anything starts.
inknumber1100How long the first line takes to ink up from a hairline.
copynumber800How long a copy takes to travel down one line and seat.
rollnumber550How long a word takes to turn over in its window.
pausenumber150The beat between one movement ending and the next beginning.

Dependencies

npm packages

  • motion

Registry items

  • utils

Installed automatically by the CLI when they are missing.

Accessibility

  • The lines are rendered as plain text inside the element you ask for — `as` — so the headline is a real heading at the level the page needs, and a second instance on the same page can be an `h2`.
  • What is read is what is finally seen. The outgoing word in each window is `aria-hidden`, so assistive technology gets the finished line and never the one it was copied from.
  • Nothing is hidden from the accessibility tree while it moves: a copy waiting for its turn is transparent, not `display: none`, so a reader who arrives mid-animation still finds every line.
  • The window's edges are a mask, not an overflow clip, and they come off once the word has turned, so a finished line is unmasked text that reflows, selects and zooms like any other.
  • It plays once and stops. Nothing loops, nothing responds to the pointer, and there is no timer left running after the last copy has seated.
  • `prefers-reduced-motion` is honoured structurally: no line moves, nothing is measured, the outgoing words are not rendered at all, and `onComplete` fires at once so whatever is cued from it still appears.
  • The ink is a weight, not a colour, so the contrast of the finished line is the contrast of your text and is never reduced by the entrance.
  • Both themes are covered by the page's own text colour rather than by anything the component sets, so it keeps its contrast inside a forced-theme subtree.

Keyboard interactions

Carbon Copy renders no interactive controls of its own, so it adds nothing to the tab order.

Customization

Take more than one copy

Every line after the first is a copy of the one before it, taken a beat after that one has seated. Only the words that differ turn over, so keep the lines the same shape and let one word carry the change — three revisions of a sentence read as one thought sharpening, where three different sentences read as a slideshow.

tsx
<CarbonCopy
  as="h2"
  lines={["Build it fast.", "Build it right.", "Build it once."]}
  className="text-5xl leading-none tracking-tight"
/>

Skip the ink

The ink is drawn from the weights between the ones a face ships, which only a variable font has. On a static face the browser steps between the cuts it does have, and a headline that snaps from thin to bold is not what the ink means. Turn it off and the first line is simply there; the copy is the whole event, which at body size is often the better proportion anyway.

tsx
<CarbonCopy
  as="p"
  align="start"
  ink={false}
  weight={500}
  lines={["Deploys in seconds.", "Rolls back in one click."]}
  className="text-xl leading-tight text-muted-foreground"
/>

Set the tempo

Five numbers own the clock, and the movement is written against them rather than in fixed durations, so a slower copy is still the same copy. A hero can afford a longer ink and a heavier seat; a line inside a card wants the defaults or quicker.

tsx
<CarbonCopy
  lines={["Ship on Friday.", "Sleep on Friday."]}
  timing={{ delay: 400, ink: 1400, copy: 950, roll: 650, pause: 240 }}
/>

Cue the rest of the section from it

A headline that performs should be the one thing performing. onComplete fires on the last seat — at once under reduced motion — so the sentence and the call to action under it can be set down after the copy has landed instead of racing it on a timer that has to be kept in step by hand.

tsx
const [settled, setSettled] = React.useState(false)

<CarbonCopy
  trigger="mount"
  lines={["Copy the code.", "Keep the code."]}
  onComplete={() => setSettled(true)}
/>
<p
  className={cn(
    "transition-[opacity,translate] duration-700",
    settled ? "translate-y-0 opacity-100" : "-translate-y-2 opacity-0"
  )}
>
  Animated React components that install into your repo and stay there.
</p>

Play it again

It has no notion of playing twice, and it should not — a headline is set once. When a demo or a route change needs it to run again, remount it: a new key is a fresh instance, measured and timed from nothing.

tsx
const [take, setTake] = React.useState(0)

<CarbonCopy key={take} lines={lines} />
<button onClick={() => setTake((n) => n + 1)}>Replay</button>
  • A hero section led by an arc of tinted glass discs, solved from a count and a spread, that assembles on mount and then answers only the pointer.

  • A coil of pills that keeps one item in focus and lets the rest twist away, turned either by a timer or by the page scroll.