Sparkline Metric Cards
KPI cards with a hand-built SVG trend line — no charting dependency for a path this file computes in twelve lines.
The moving price rail that sits under an exchange’s navbar — direction carried by an arrow and a sign as well as by colour, and the duplicate half dropped rather than read twice.
components/price-ticker-strip.tsxnpx hoverlab add price-ticker-strip
Or over MCP, from your editor's agent — no account needed.
Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.
Start a page with this section — add more, order them, and leave with the page source.
Indicative prices, delayed 15 minutes
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
/**
* <PriceTickerStrip> — the moving price rail every exchange puts under its nav.
*
* It is the one piece of furniture that says "this is a market" before a
* word of copy is read, and it is the thing a fintech landing page built
* from generic marketing blocks always lacks.
*
* THREE THINGS IT DOES THAT A NAIVE TICKER GETS WRONG.
*
* 1. *Direction is never colour alone.* Red and green are the convention
* and they are also 1.4.1 — roughly one man in twelve cannot separate
* them, and a strip where the only difference between "up 4%" and "down
* 4%" is hue is unreadable to him. Every row carries an arrow glyph and
* a signed number, so the colour is the third cue rather than the only
* one.
*
* 2. *The duplicate half is `aria-hidden`.* A seamless marquee needs the
* list twice; announcing it twice would read every price twice. Same
* call as <LogoCloud>, and the reason it is worth restating is that the
* obvious fix — a CSS-only duplicate via `content` — cannot be styled
* per row.
*
* 3. *It stops.* `motion-safe:` gates the animation, so a reader with
* `prefers-reduced-motion` gets the same prices as a static wrapped row
* rather than nothing; hovering pauses it, because a price you are
* trying to read should not slide out from under the pointer. Vestibular
* triggers aside, an unpausable ticker is simply hostile.
*
* PRICES ARE PRE-FORMATTED STRINGS. Not a `number` plus `Intl.NumberFormat`
* at render: the server's locale is not the browser's, and a ticker that
* renders "63,204.10" on the server and "63 204,10" in the client is a
* hydration mismatch that throws the whole subtree away. Format upstream,
* where you know the user's locale, and pass strings.
*
* There is no live data here and the block does not pretend otherwise —
* `asOf` names when these numbers were true. Wire it to a socket and
* replace the array; nothing about the layout changes.
*/
import * as React from 'react'
import { ArrowUpRight, ArrowDownRight } from 'lucide-react'
export interface TickerQuote {
/** Ticker symbol — "BTC", "EUR/USD", "AAPL". */
symbol: string
/** The full name, shown under the symbol on the expanded variant. */
name?: string
/** Pre-formatted, currency symbol included. See the header. */
price: string
/** Signed and suffixed by you — "+4.12%", "−0.30%". */
change: string
direction: 'up' | 'down' | 'flat'
}
export interface PriceTickerStripProps {
quotes?: TickerQuote[]
/** Seconds for one full pass. Longer is calmer; 60 is about right for 8. */
duration?: number
/** When these numbers were true. A market strip with no timestamp lies. */
asOf?: string
/** Hairlines above and below, for sitting the strip under a navbar. */
divided?: boolean
className?: string
}
const DEFAULT_QUOTES: TickerQuote[] = [
{ symbol: 'BTC', name: 'Bitcoin', price: '$63,204.10', change: '+2.41%', direction: 'up' },
{ symbol: 'ETH', name: 'Ethereum', price: '$3,118.62', change: '+1.08%', direction: 'up' },
{ symbol: 'SOL', name: 'Solana', price: '$147.93', change: '−3.22%', direction: 'down' },
{ symbol: 'USDC', name: 'USD Coin', price: '$1.0001', change: '0.00%', direction: 'flat' },
{ symbol: 'XRP', name: 'XRP', price: '$0.5284', change: '−0.94%', direction: 'down' },
{ symbol: 'ADA', name: 'Cardano', price: '$0.4471', change: '+5.67%', direction: 'up' },
{ symbol: 'AVAX', name: 'Avalanche', price: '$27.84', change: '+0.72%', direction: 'up' },
{ symbol: 'DOT', name: 'Polkadot', price: '$6.19', change: '−1.55%', direction: 'down' },
]
/*
Utility classes rather than a style object, because these tokens are
complete oklch() colours — `hsl(var(--primary))` would be dropped by the
parser and fall back to inherited, which typechecks and renders invisible.
`flat` deliberately gets the muted colour: nothing moved, so nothing
should catch the eye.
*/
const DIRECTION_CLASS: Record<TickerQuote['direction'], string> = {
up: 'text-emerald-600 dark:text-emerald-400',
down: 'text-rose-600 dark:text-rose-400',
flat: 'text-muted-foreground',
}
function DirectionGlyph({ direction }: { direction: TickerQuote['direction'] }) {
if (direction === 'up') return <ArrowUpRight aria-hidden className="h-3.5 w-3.5" />
if (direction === 'down') return <ArrowDownRight aria-hidden className="h-3.5 w-3.5" />
/* An en dash, not an arrow: "flat" has no direction to point in. */
return (
<span aria-hidden className="text-xs leading-none">
–
</span>
)
}
function Quote({ quote, hidden }: { quote: TickerQuote; hidden: boolean }) {
return (
<div
aria-hidden={hidden || undefined}
/* The duplicate half exists only to make the loop seamless. Under
`prefers-reduced-motion` the rail stops scrolling and wraps, and a
wrapped rail that lists every price twice is a bug rather than a
seam — so the copy is dropped entirely there. <LogoCloud> tolerates
the repeat because a repeated wordmark reads as a pattern; a
repeated price reads as two different quotes. */
className={`flex shrink-0 items-center gap-2.5 whitespace-nowrap ${
hidden ? 'motion-reduce:hidden' : ''
}`}
>
<span className="text-sm font-bold tracking-tight text-foreground">{quote.symbol}</span>
<span className="text-sm tabular-nums text-muted-foreground">{quote.price}</span>
<span
className={`inline-flex items-center gap-0.5 text-xs font-semibold tabular-nums ${
DIRECTION_CLASS[quote.direction]
}`}
>
<DirectionGlyph direction={quote.direction} />
{quote.change}
{/* The word the colour and the arrow are both standing in for. It
is never rendered, which is the point — it exists so the row
reads correctly out loud without adding a fourth visible cue. */}
<span className="sr-only">
{quote.direction === 'up' ? ' up' : quote.direction === 'down' ? ' down' : ' unchanged'}
</span>
</span>
</div>
)
}
export function PriceTickerStrip({
quotes = DEFAULT_QUOTES,
duration = 60,
asOf = 'Indicative prices, delayed 15 minutes',
divided = true,
className = '',
}: PriceTickerStripProps) {
return (
<section
aria-label="Market prices"
className={`w-full bg-card/60 ${divided ? 'border-y border-border/60' : ''} ${className}`}
>
<div
className="group relative overflow-hidden py-2.5"
style={{
maskImage: 'linear-gradient(to right, transparent, black 6%, black 94%, transparent)',
WebkitMaskImage:
'linear-gradient(to right, transparent, black 6%, black 94%, transparent)',
}}
>
<div
className="flex w-max items-center gap-8 motion-safe:animate-[price-ticker_var(--ticker-duration)_linear_infinite] motion-reduce:w-full motion-reduce:flex-wrap motion-reduce:justify-center motion-reduce:gap-x-8 motion-reduce:gap-y-2 group-hover:[animation-play-state:paused]"
style={{ ['--ticker-duration' as string]: `${duration}s` }}
>
{[...quotes, ...quotes].map((quote, i) => (
<Quote key={`${quote.symbol}-${i}`} quote={quote} hidden={i >= quotes.length} />
))}
</div>
</div>
{asOf ? (
<p className="px-4 pb-2 text-center text-[11px] text-muted-foreground/85 sm:px-6">
{asOf}
</p>
) : null}
<style>{`
@keyframes price-ticker {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
`}</style>
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/price-ticker-strip.tsx and import it where you need the section:
import { PriceTickerStrip } from '@/components/price-ticker-strip'3 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.
Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.
| Prop | Type | Default |
|---|---|---|
quotes | TickerQuote[] | DEFAULT_QUOTES |
durationSeconds for one full pass. Longer is calmer; 60 is about right for 8. | number | 60 |
asOfWhen these numbers were true. A market strip with no timestamp lies. | string | 'Indicative prices, delayed 15 minutes' |
dividedHairlines above and below, for sitting the strip under a navbar. | boolean | true |
className | string | '' |
The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.
<!--
Market Price Ticker — markup from the Hoverlab catalog.
This is the block rendered once to HTML and wrapped as a component
file. It is not a port of the React source: the Tailwind classes carry
the design, which is the part that took the work, and they are the same
in every framework.
This block has no interactive behaviour, so nothing is missing.
-->
<section aria-label="Market prices" class="w-full bg-card/60 border-y border-border/60 ">
<div class="group relative overflow-hidden py-2.5" style="mask-image:linear-gradient(to right, transparent, black 6%, black 94%, transparent);-webkit-mask-image:linear-gradient(to right, transparent, black 6%, black 94%, transparent)">
<div class="flex w-max items-center gap-8 motion-safe:animate-[price-ticker_var(--ticker-duration)_linear_infinite] motion-reduce:w-full motion-reduce:flex-wrap motion-reduce:justify-center motion-reduce:gap-x-8 motion-reduce:gap-y-2 group-hover:[animation-play-state:paused]" style="--ticker-duration:60s">
<div class="flex shrink-0 items-center gap-2.5 whitespace-nowrap ">
<span class="text-sm font-bold tracking-tight text-foreground">BTC</span>
<span class="text-sm tabular-nums text-muted-foreground">$63,204.10</span>
<span class="inline-flex items-center gap-0.5 text-xs font-semibold tabular-nums text-emerald-600 dark:text-emerald-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-up-right h-3.5 w-3.5" aria-hidden="true"><path d="M7 7h10v10"></path><path d="M7 17 17 7"></path></svg>+2.41%<span class="sr-only"> up</span></span>
</div>
<div class="flex shrink-0 items-center gap-2.5 whitespace-nowrap ">
<span class="text-sm font-bold tracking-tight text-foreground">ETH</span>
<span class="text-sm tabular-nums text-muted-foreground">$3,118.62</span>
<span class="inline-flex items-center gap-0.5 text-xs font-semibold tabular-nums text-emerald-600 dark:text-emerald-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-up-right h-3.5 w-3.5" aria-hidden="true"><path d="M7 7h10v10"></path><path d="M7 17 17 7"></path></svg>+1.08%<span class="sr-only"> up</span></span>
</div>
<div class="flex shrink-0 items-center gap-2.5 whitespace-nowrap ">
<span class="text-sm font-bold tracking-tight text-foreground">SOL</span>
<span class="text-sm tabular-nums text-muted-foreground">$147.93</span>
<span class="inline-flex items-center gap-0.5 text-xs font-semibold tabular-nums text-rose-600 dark:text-rose-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-down-right h-3.5 w-3.5" aria-hidden="true"><path d="m7 7 10 10"></path><path d="M17 7v10H7"></path></svg>−3.22%<span class="sr-only"> down</span></span>
</div>
<div class="flex shrink-0 items-center gap-2.5 whitespace-nowrap ">
<span class="text-sm font-bold tracking-tight text-foreground">USDC</span>
<span class="text-sm tabular-nums text-muted-foreground">$1.0001</span>
<span class="inline-flex items-center gap-0.5 text-xs font-semibold tabular-nums text-muted-foreground"><span aria-hidden="true" class="text-xs leading-none">–</span>0.00%<span class="sr-only"> unchanged</span></span>
</div>
<div class="flex shrink-0 items-center gap-2.5 whitespace-nowrap ">
<span class="text-sm font-bold tracking-tight text-foreground">XRP</span>
<span class="text-sm tabular-nums text-muted-foreground">$0.5284</span>
<span class="inline-flex items-center gap-0.5 text-xs font-semibold tabular-nums text-rose-600 dark:text-rose-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-down-right h-3.5 w-3.5" aria-hidden="true"><path d="m7 7 10 10"></path><path d="M17 7v10H7"></path></svg>−0.94%<span class="sr-only"> down</span></span>
</div>
<div class="flex shrink-0 items-center gap-2.5 whitespace-nowrap ">
<span class="text-sm font-bold tracking-tight text-foreground">ADA</span>
<span class="text-sm tabular-nums text-muted-foreground">$0.4471</span>
<span class="inline-flex items-center gap-0.5 text-xs font-semibold tabular-nums text-emerald-600 dark:text-emerald-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-up-right h-3.5 w-3.5" aria-hidden="true"><path d="M7 7h10v10"></path><path d="M7 17 17 7"></path></svg>+5.67%<span class="sr-only"> up</span></span>
</div>
<div class="flex shrink-0 items-center gap-2.5 whitespace-nowrap ">
<span class="text-sm font-bold tracking-tight text-foreground">AVAX</span>
<span class="text-sm tabular-nums text-muted-foreground">$27.84</span>
<span class="inline-flex items-center gap-0.5 text-xs font-semibold tabular-nums text-emerald-600 dark:text-emerald-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-up-right h-3.5 w-3.5" aria-hidden="true"><path d="M7 7h10v10"></path><path d="M7 17 17 7"></path></svg>+0.72%<span class="sr-only"> up</span></span>
</div>
<div class="flex shrink-0 items-center gap-2.5 whitespace-nowrap ">
<span class="text-sm font-bold tracking-tight text-foreground">DOT</span>
<span class="text-sm tabular-nums text-muted-foreground">$6.19</span>
<span class="inline-flex items-center gap-0.5 text-xs font-semibold tabular-nums text-rose-600 dark:text-rose-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-down-right h-3.5 w-3.5" aria-hidden="true"><path d="m7 7 10 10"></path><path d="M17 7v10H7"></path></svg>−1.55%<span class="sr-only"> down</span></span>
</div>
<div aria-hidden="true" class="flex shrink-0 items-center gap-2.5 whitespace-nowrap motion-reduce:hidden">
<span class="text-sm font-bold tracking-tight text-foreground">BTC</span>
<span class="text-sm tabular-nums text-muted-foreground">$63,204.10</span>
<span class="inline-flex items-center gap-0.5 text-xs font-semibold tabular-nums text-emerald-600 dark:text-emerald-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-up-right h-3.5 w-3.5" aria-hidden="true"><path d="M7 7h10v10"></path><path d="M7 17 17 7"></path></svg>+2.41%<span class="sr-only"> up</span></span>
</div>
<div aria-hidden="true" class="flex shrink-0 items-center gap-2.5 whitespace-nowrap motion-reduce:hidden">
<span class="text-sm font-bold tracking-tight text-foreground">ETH</span>
<span class="text-sm tabular-nums text-muted-foreground">$3,118.62</span>
<span class="inline-flex items-center gap-0.5 text-xs font-semibold tabular-nums text-emerald-600 dark:text-emerald-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-up-right h-3.5 w-3.5" aria-hidden="true"><path d="M7 7h10v10"></path><path d="M7 17 17 7"></path></svg>+1.08%<span class="sr-only"> up</span></span>
</div>
<div aria-hidden="true" class="flex shrink-0 items-center gap-2.5 whitespace-nowrap motion-reduce:hidden">
<span class="text-sm font-bold tracking-tight text-foreground">SOL</span>
<span class="text-sm tabular-nums text-muted-foreground">$147.93</span>
<span class="inline-flex items-center gap-0.5 text-xs font-semibold tabular-nums text-rose-600 dark:text-rose-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-down-right h-3.5 w-3.5" aria-hidden="true"><path d="m7 7 10 10"></path><path d="M17 7v10H7"></path></svg>−3.22%<span class="sr-only"> down</span></span>
</div>
<div aria-hidden="true" class="flex shrink-0 items-center gap-2.5 whitespace-nowrap motion-reduce:hidden">
<span class="text-sm font-bold tracking-tight text-foreground">USDC</span>
<span class="text-sm tabular-nums text-muted-foreground">$1.0001</span>
<span class="inline-flex items-center gap-0.5 text-xs font-semibold tabular-nums text-muted-foreground"><span aria-hidden="true" class="text-xs leading-none">–</span>0.00%<span class="sr-only"> unchanged</span></span>
</div>
<div aria-hidden="true" class="flex shrink-0 items-center gap-2.5 whitespace-nowrap motion-reduce:hidden">
<span class="text-sm font-bold tracking-tight text-foreground">XRP</span>
<span class="text-sm tabular-nums text-muted-foreground">$0.5284</span>
<span class="inline-flex items-center gap-0.5 text-xs font-semibold tabular-nums text-rose-600 dark:text-rose-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-down-right h-3.5 w-3.5" aria-hidden="true"><path d="m7 7 10 10"></path><path d="M17 7v10H7"></path></svg>−0.94%<span class="sr-only"> down</span></span>
</div>
<div aria-hidden="true" class="flex shrink-0 items-center gap-2.5 whitespace-nowrap motion-reduce:hidden">
<span class="text-sm font-bold tracking-tight text-foreground">ADA</span>
<span class="text-sm tabular-nums text-muted-foreground">$0.4471</span>
<span class="inline-flex items-center gap-0.5 text-xs font-semibold tabular-nums text-emerald-600 dark:text-emerald-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-up-right h-3.5 w-3.5" aria-hidden="true"><path d="M7 7h10v10"></path><path d="M7 17 17 7"></path></svg>+5.67%<span class="sr-only"> up</span></span>
</div>
<div aria-hidden="true" class="flex shrink-0 items-center gap-2.5 whitespace-nowrap motion-reduce:hidden">
<span class="text-sm font-bold tracking-tight text-foreground">AVAX</span>
<span class="text-sm tabular-nums text-muted-foreground">$27.84</span>
<span class="inline-flex items-center gap-0.5 text-xs font-semibold tabular-nums text-emerald-600 dark:text-emerald-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-up-right h-3.5 w-3.5" aria-hidden="true"><path d="M7 7h10v10"></path><path d="M7 17 17 7"></path></svg>+0.72%<span class="sr-only"> up</span></span>
</div>
<div aria-hidden="true" class="flex shrink-0 items-center gap-2.5 whitespace-nowrap motion-reduce:hidden">
<span class="text-sm font-bold tracking-tight text-foreground">DOT</span>
<span class="text-sm tabular-nums text-muted-foreground">$6.19</span>
<span class="inline-flex items-center gap-0.5 text-xs font-semibold tabular-nums text-rose-600 dark:text-rose-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-down-right h-3.5 w-3.5" aria-hidden="true"><path d="m7 7 10 10"></path><path d="M17 7v10H7"></path></svg>−1.55%<span class="sr-only"> down</span></span>
</div>
</div>
</div>
<p class="px-4 pb-2 text-center text-[11px] text-muted-foreground/85 sm:px-6">Indicative prices, delayed 15 minutes</p>
<style>@keyframes price-ticker {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}</style>
</section>
What each framework gets across the whole catalog — effects convert properly; this rung is markup.
The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.
Want the whole screen instead of this one section? Open a page and copy it entire.
All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.
Browse Charts & MetricsCopying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.
KPI cards with a hand-built SVG trend line — no charting dependency for a path this file computes in twelve lines.
Categorical bars in plain CSS with gridlines, hover values and a screen-reader table carrying the real numbers.
Consumption against plan limits, with warning thresholds, honest overage display and unmetered quotas that do not fake a bar.
A year-over-year trend in inline SVG with a printed axis, no charting dependency and the same data repeated as a screen-reader table.
Where a total went, drawn as one conic-gradient ring with the total in the hole and every number repeated in the legend.
A year of daily activity as one square per day, bucketed against a quantile so a single outlier cannot flatten the scale.
A funnel that leads with the step-to-step rate and the people who fell out of it, not the tidy share-of-top percentages nobody can act on. Biggest drop named in words, and a footnote saying which definition of funnel this is.
Alert thresholds with their current distance from firing, so a quiet alert is visibly armed rather than possibly broken.
The top-of-dashboard figures, each carrying the comparison period that makes a delta mean anything.