Step-End Blinking Caret
A bar caret that blinks the way a terminal cursor does: fully on, then fully off, with nothing in between. The switch is `animation-timing-function: step-end`, which holds each keyframe value until the next one rather than interpolating toward it. The keyframe distribution is deliberately uneven so the rhythm reads as alive rather than as a metronome, and the whole thing is CSS — no JavaScript runs after mount.
Step-End Blinking Caret
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/step-end-caret.jsonNo runtime dependencies.
Prompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
Source
One file, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| width | number | = 2 | Bar width in pixels. |
| height | string | = '1em' | Any CSS length. em keeps it locked to the surrounding type size. |
| period | number | = 1.05 | Seconds for one full on-off-on-off cycle. |
| paused | boolean | = false | Hold the caret solid — what a terminal does while keys are down. |
| className | string | — | Merged onto the bar via cn. |
How it works
The technique in order, not the code in order.
A single
@keyframesrule setsopacity: 1at 0%, 70% and 100%, andopacity: 0at 20% and 50%. Withstep-end, each value is held until the following keyframe is reached, so opacity is only ever exactly 1 or exactly 0.The shorthand
animation: var(--kx-caret-period, 1.05s) step-end infinite kinetic-caret-blinkputs the period in a custom property, so theperiodprop varies the speed through an inlinestylewithout generating a new class or a new keyframe rule.The bar itself is an
inline-blockspan withbackground-color: currentColor, so it inherits the colour of the text it sits in, andvertical-align: -0.14emto seat it on the baseline of the surrounding type rather than the box.The
pausedstate setsanimation: none; opacity: 1rather thananimation-play-state: paused— pausing freezes on whichever frame the clock happened to reach, which is a coin flip between a visible caret and an invisible one.The stylesheet is emitted through React 19's
<style href precedence>hoisting, so the rule is deduplicated byhrefand a hundred carets on a page produce exactly one rule in the document.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| period | = 1.05 | Seconds per full on-off-on-off cycle. Terminal defaults sit near 1s; below ~0.6s it reads as an error indicator, above ~1.6s the reader stops associating the blink with the text it follows. |
| keyframe stops | = 0/20/50/70/100% | On 0-20, off 20-50, on 50-70, off 70-100. An even 50/50 square wave reads as a metronome. The long-on / short-off asymmetry is what makes it read as something alive rather than as a timer. |
| width | = 2 | Pixels. 1px disappears at typical body sizes on a low-DPI display; past 3px it stops being a caret and starts being a block cursor, which implies overtype mode. |
| vertical-align | = -0.14em | Seats the bar on the text baseline. At 0 the caret floats above the descender line and looks like a stray rule; in em it tracks the type size, so the same caret is correct at 14px and at 72px. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
A @media (prefers-reduced-motion: reduce) block inside the component stylesheet sets animation: none; opacity: 1. The caret remains fully visible and correctly positioned — it simply stops blinking. It is never left in the off state.
Nothing to clean up. There is no effect, no timer and no listener; unmounting removes the span, and React's stylesheet hoisting handles the shared rule.
The bar is either fully opaque or fully invisible at every moment — screenshot it repeatedly and no intermediate opacity appears. Set paused and it goes solid immediately rather than freezing wherever it was. Render ten carets and inspect the document: one kinetic-step-end-caret stylesheet. Enable reduced motion and the caret is present and solid.
Pitfalls
- Leaving the default
easetiming function. The opacity interpolates and the caret pulses like a glow, which reads as decoration; a cursor must be unambiguously present or absent. - Using
animation-play-state: pausedfor the held state. It stops on an arbitrary frame, so roughly half the time the "solid" caret is invisible and the user thinks the field lost focus. - A symmetric 50/50 blink. It reads as mechanical timekeeping rather than as a cursor waiting for input.
- Hardcoding the caret colour.
background-color: currentColoris what lets the same component sit inside inverted, accented or muted text without a variant per context. - Emitting the keyframes per instance in a styled-component or inline
<style>without a precedence key. Ten carets means ten identical rule sets the style engine has to resolve. - Exposing the caret to assistive technology. It is
aria-hidden— a screen reader announcing a decorative bar between every phrase is noise.
Notes
- No JavaScript executes after mount: no rAF loop, no timer, no listener, nothing to clean up. The component is a span and a stylesheet.
- Designed to be composed —
asymmetric-typewriterrenders this component and drives itspausedprop from its own typing state.