Comic Slider
A halftone track with a starburst thumb and the value in a speech bubble.
betaComicSlider.tsx
Examples
Basic
Drag it or use the arrow keys; the burst turns as it goes.
comic-slider/basic.tsx
"use client";
import { useState } from "react";
import { ComicSlider } from "@streamuilib/react";
export default function ComicSliderBasic() {
const [power, setPower] = useState(60);
return (
<label className="flex w-full max-w-sm flex-col gap-1">
<span className="font-bold">Power level</span>
<ComicSlider value={power} onValueChange={setPower} formatValue={(v) => `${v}%`} />
</label>
);
}Tones, ranges, and formats
comic-slider/tones.tsx
"use client";
import { useState } from "react";
import { ComicSlider } from "@streamuilib/react";
export default function ComicSliderTones() {
const [volume, setVolume] = useState(11);
const [speed, setSpeed] = useState(3);
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<label className="flex flex-col gap-1">
<span className="font-bold">Volume</span>
<ComicSlider value={volume} max={11} onValueChange={setVolume} tone="pink" formatValue={(v) => (v === 11 ? "11!!" : v)} />
</label>
<label className="flex flex-col gap-1">
<span className="font-bold">Speed</span>
<ComicSlider value={speed} min={1} max={5} onValueChange={setSpeed} tone="blue" formatValue={(v) => `${v}x`} aria-valuetext={`${speed} times`} />
</label>
<label className="flex flex-col gap-1">
<span className="font-bold">Quiet, no bubble</span>
<ComicSlider value={40} tone="orange" showValue={false} disabled />
</label>
</div>
);
}Installation
bun add @streamuilib/reactusage.tsx
import { ComicSlider } from "@streamuilib/react";
<ComicSlider value={power} onValueChange={setPower} aria-label="Power" />API
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number | — | Current value (controlled). |
| onValueChange | (value: number) => void | — | Called with the new value. |
| min / max / step | number | 0 / 100 / 1 | The range. |
| tone | "lime" | "pink" | "blue" | "violet" | "orange" | "lime" | Fill color. |
| showValue | boolean | true | The value bubble over the thumb. |
| formatValue | (value: number) => ReactNode | — | Formats the bubble's text. |
| ...props | ComponentProps<"input"> | — | Native range attributes, like aria-label and aria-valuetext. |
Accessibility
- A native range input sits on top, invisible, so arrow keys, Page Up and Down, Home and End, touch, and screen readers all work.
- Pass aria-valuetext when the number alone doesn't say enough.
- The bubble, track, and thumb are drawn for sight only and hidden.
Source
packages/react/src/ComicSlider.tsx
"use client";
import type { CSSProperties, ReactNode } from "react";
import { cn } from "./cn";
import { halftone } from "./comic";
import { SHAPES } from "./ComicBadge";
import type { SliderProps } from "./Slider";
import { toneBg } from "./tones";
export type ComicSliderProps = SliderProps & {
/** Show the value in a speech bubble over the thumb. */
showValue?: boolean;
/** Format the value for the bubble, e.g. `(v) => v + "%"`. */
formatValue?: (value: number) => ReactNode;
};
/**
* A comic-book slider: a halftone track with a starburst thumb and the value
* in a speech bubble over it. A native range input sits on top (invisible),
* so arrow keys, Page Up/Down, Home/End, touch, and screen readers all work.
*/
export function ComicSlider({
value,
min = 0,
max = 100,
step = 1,
onValueChange,
onChange,
tone = "lime",
showValue = true,
formatValue = String,
className,
style,
...props
}: ComicSliderProps) {
const span = max - min;
const fill = span > 0 ? (Math.min(max, Math.max(min, value)) - min) / span : 0;
// The native thumb is 2rem wide (theme.css), so its center runs from 1rem to 100% - 1rem.
const at = `calc(1rem + (100% - 2rem) * ${fill})`;
return (
<div className={cn("sui-comic-slider relative h-9 w-full", showValue && "mt-9", className)} style={style}>
<input
type="range"
value={value}
min={min}
max={max}
step={step}
onChange={(e) => {
onChange?.(e);
onValueChange?.(Number(e.target.value));
}}
className="sui-comic-range peer absolute inset-0 z-10 size-full cursor-pointer opacity-0 disabled:cursor-not-allowed"
{...props}
/>
<div
aria-hidden
className="absolute inset-x-0 top-1/2 h-4 -translate-y-1/2 overflow-hidden rounded-full border-[3px] border-ink bg-paper-sunken shadow-[3px_3px_0_var(--ink)] peer-disabled:opacity-50"
>
<div className={cn("h-full border-r-[3px] border-ink", toneBg[tone])} style={{ width: at, ...halftone(16) }} />
</div>
<span
aria-hidden
className="sui-comic-slider-thumb pointer-events-none absolute top-1/2 size-9 -translate-1/2 rounded-full peer-focus-visible:outline-3 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-blue peer-active:scale-110"
style={{ left: at, "--sui-turn": Math.round(fill * 270) } as CSSProperties}
>
<svg viewBox="0 0 100 100" overflow="visible" className="size-full drop-shadow-[2px_2px_0_var(--ink)]">
<path d={SHAPES.burst.d} className="fill-paper-raised stroke-ink" strokeWidth={7} strokeLinejoin="round" />
</svg>
</span>
{showValue && (
<span
aria-hidden
className="pointer-events-none absolute -top-9 -translate-x-1/2 rounded-lg border-[3px] border-ink bg-paper-raised px-2 py-0.5 font-mono text-xs font-bold whitespace-nowrap text-ink shadow-[2px_2px_0_var(--ink)] after:absolute after:top-full after:left-1/2 after:-mt-px after:-ml-1.5 after:border-x-[6px] after:border-t-[7px] after:border-x-transparent after:border-t-ink"
style={{ left: at }}
>
{formatValue(value)}
</span>
)}
</div>
);
}