COMPONENT / data-readout-carousel
Data-Readout Carousel_
Framed slide viewer with a --signal 01 / 08 counter, bracketed PREV / NEXT buttons, arrow-key stepping and a segment strip. Slides change with a short stepped wipe, instant under reduced motion. Requires @cybr/tokens and @cybr/effects.
Install
1. Map the
@cybrnamespace, once per project. Add this inside yourcomponents.json, or into its existing"registries"object. Nocomponents.jsonyet? Runnpx shadcn@latest initfirst to create one."registries": { "@cybr": "https://cybr-ui.com/r/{name}.json" }2. Add components:
$ npx shadcn@latest add @cybr/data-readout-carousel
If shadcn reports Unknown registry "@cybr", step 1 is missing from that project's components.json.
Preview
"use client";
import { useState, type ComponentProps, type KeyboardEvent, type ReactNode } from "react";
import { cn } from "@/lib/utils";
const button =
"flex min-h-11 cursor-pointer items-center gap-1.5 border-0 bg-transparent px-4 font-mono text-xs font-bold tracking-label text-ink aria-[disabled=false]:hover:bg-bg aria-disabled:cursor-not-allowed aria-disabled:text-dim focus-visible:outline-(length:--focus-width) focus-visible:outline-offset-[-3px] focus-visible:outline-solid focus-visible:outline-signal";
function pad(n: number) {
return String(n).padStart(2, "0");
}
function isTextField(target: EventTarget) {
return (
target instanceof HTMLElement &&
(target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName))
);
}
// Framed slide viewer with a 01 / 08 counter, PREV / NEXT buttons and a
// decorative segment strip. Inactive slides stay in the DOM with `hidden`, so
// the server and no-JS render show slide 1. After the first change, each slide
// that is shown plays a short stepped wipe (off under reduced motion), because
// CSS animations restart when an element leaves display: none. The end buttons
// use aria-disabled, not disabled, so focus stays on them at the first or last
// slide and the arrow keys keep working.
export function DataReadoutCarousel({
label,
slides,
index,
defaultIndex = 0,
onIndexChange,
loop = false,
className,
onKeyDown,
...props
}: Omit<ComponentProps<"section">, "children" | "aria-label"> & {
label: string;
slides: ReactNode[];
index?: number;
defaultIndex?: number;
onIndexChange?: (index: number) => void;
loop?: boolean;
}) {
const [inner, setInner] = useState(defaultIndex);
const [moved, setMoved] = useState(false);
const total = slides.length;
const raw = index ?? inner;
const current = total === 0 ? 0 : Math.min(total - 1, Math.max(0, Number.isFinite(raw) ? Math.trunc(raw) : 0));
const canPrev = total > 1 && (loop || current > 0);
const canNext = total > 1 && (loop || current < total - 1);
const go = (step: -1 | 1) => {
if (step === -1 ? !canPrev : !canNext) return;
const next = (current + step + total) % total;
if (index === undefined) setInner(next);
setMoved(true);
onIndexChange?.(next);
};
return (
<section
aria-roledescription="carousel"
aria-label={label}
className={cn("flex flex-col border-(length:--border-base) border-line bg-bg font-mono", className)}
onKeyDown={(event: KeyboardEvent<HTMLElement>) => {
onKeyDown?.(event);
if (event.defaultPrevented || isTextField(event.target)) return;
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
go(event.key === "ArrowLeft" ? -1 : 1);
}
}}
{...props}
>
<div className="flex items-center justify-between gap-4 border-b-(length:--border-base) border-line bg-ground-deep px-5 py-3 text-xs tracking-label">
<span className="text-label">{label}</span>
<span aria-hidden="true" className="tabular-nums">
<span className="text-signal">{pad(total === 0 ? 0 : current + 1)}</span>
<span className="text-label"> / {pad(total)}</span>
</span>
</div>
<div aria-live="polite" className="relative">
{slides.map((slide, i) => (
<div
key={i}
role="group"
aria-roledescription="slide"
aria-label={`${i + 1} of ${total}`}
hidden={i !== current}
className={cn(moved && "wipe-reveal [animation-duration:300ms]")}
>
{slide}
</div>
))}
</div>
<div className="flex items-stretch justify-between gap-4 border-t-(length:--border-base) border-line bg-ground-deep">
<button type="button" aria-label="Previous slide" aria-disabled={!canPrev} onClick={() => go(-1)} className={button}>
<span aria-hidden="true">[ ← PREV ]</span>
</button>
<span
aria-hidden="true"
className="grid max-w-60 flex-1 items-center gap-1"
style={{ gridTemplateColumns: `repeat(${Math.max(1, total)}, minmax(0, 1fr))` }}
>
{slides.map((_, i) => (
<span key={i} className={cn("h-1.5", i === current ? "bg-signal" : "bg-line")} />
))}
</span>
<button type="button" aria-label="Next slide" aria-disabled={!canNext} onClick={() => go(1)} className={button}>
<span aria-hidden="true">[ NEXT → ]</span>
</button>
</div>
</section>
);
}
Repo source. The shadcn CLI rewrites import aliases to match your project.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | — | Required. The carousel's accessible name, also shown in the header bar. |
| slides | ReactNode[] | — | Slide contents in order. Give each a key. |
| defaultIndex / index | number | 0 | Starting slide (uncontrolled) or the current slide (controlled). Values out of range clamp to the first or last slide. |
| onIndexChange | (index: number) => void | — | Called with the new index when PREV, NEXT or an arrow key changes the slide. |
| loop | boolean | false | Wrap from the last slide to the first and back. Without it, the buttons disable at the ends. |
| className | string | — | Extra classes, merged after the defaults with cn(). |
| ...props | Omit<ComponentProps<"section">, "children" | "aria-label"> | — | Every other native <section> attribute and ref is forwarded. Your onKeyDown runs first; call preventDefault() to stop the arrow keys. |
Usage
Needs both stylesheets from @cybr/tokens and @cybr/effects. After the install, import them in app/globals.css:
@import "tailwindcss";
@import "../styles/cybr-tokens.css";
@import "../styles/cybr-effects.css";
import { DataReadoutCarousel } from "@/components/ui/data-readout-carousel";
export function Readouts() {
return (
<DataReadoutCarousel
label="SYSTEM READOUT"
slides={[<p key="a">Uptime 99.98%</p>, <p key="b">Latency 42ms</p>]}
/>
);
}
Behaviour
- Counter: the current slide in
--signaland the total in--label, both padded to 2 digits (01 / 04). - Stepping: PREV and NEXT move one slide. Left and Right arrow keys do the same while focus is anywhere in the carousel, except in a text field inside a slide.
- Ends: without
loop, PREV is disabled on the first slide and NEXT on the last, shown as--dimtext with a not-allowed cursor. Withloopthey wrap. - Wipe: after the first change, each new slide wipes in from the left in stepped 300ms frames. The first slide never animates on load.
- Static: before hydration and without JavaScript, slide 1 shows and the other slides are hidden. Under
prefers-reduced-motionslides swap instantly. - Empty: with no slides the counter reads
00 / 00and both buttons are disabled. With one slide both buttons are disabled. - No autoplay, so there is nothing to pause. Swipe is not built in; the buttons work on touch.
Accessibility
- A
<section>witharia-roledescription="carousel"and yourlabelas its name. Each slide is a group witharia-roledescription="slide"and a label like "2 of 4". - The slide area is
aria-live="polite", so screen readers announce the new slide after PREV or NEXT. Hidden slides are out of the accessibility tree. - The buttons are named "Previous slide" and "Next slide"; the bracket and arrow glyphs are
aria-hidden. At the ends the buttons arearia-disabledrather thandisabled, so a focused button keeps focus and the arrow keys keep working; activating it does nothing. - The counter and segment strip are
aria-hidden, because the slide labels carry the position. --labelon--ground-deepis 8.51:1, the--signalcounter on--ground-deepis 5.31:1 and button text is--inkon--ground-deepat 16.74:1.