Skip to content

Instantly share code, notes, and snippets.

@JPaulDuncan
Created September 1, 2025 14:14
Show Gist options
  • Select an option

  • Save JPaulDuncan/2df1068568c19df46fa5d28af705a30e to your computer and use it in GitHub Desktop.

Select an option

Save JPaulDuncan/2df1068568c19df46fa5d28af705a30e to your computer and use it in GitHub Desktop.
Express+Typescript: Accessible, reusable 1–5 interactive star rating component
import React, { useMemo, useRef, useState } from "react";
import { Star } from "lucide-react";
export type InteractiveStarsProps = {
/** Current rating (0–max). Controlled. */
value: number;
/** Called when the user selects a rating. */
onChange: (val: number) => void | Promise<void>;
/** Number of stars to render (default 5). */
max?: number;
/** Icon size in px (default 16). */
size?: number;
/** Disable interaction but keep visual (default false). */
disabled?: boolean;
/** Read-only: same as disabled, but aria reflects readOnly control. */
readOnly?: boolean;
/** Allow clearing by clicking the same value again (default true). */
allowClear?: boolean;
/** Label announced to screen readers (default "Rate"). */
ariaLabel?: string;
/** Optional className on the container. */
className?: string;
};
export function InteractiveStars({
value,
onChange,
max = 5,
size = 16,
disabled = false,
readOnly = false,
allowClear = true,
ariaLabel = "Rate",
className,
}: InteractiveStarsProps){
const [hover, setHover] = useState<number | null>(null);
const buttonsRef = useRef<Array<HTMLButtonElement | null>>([]);
const isInteractive = !disabled && !readOnly;
const stars = useMemo(() => Array.from({ length: max }, (_, i) => i + 1), [max]);
const display = hover ?? value;
function commit(next: number){
if (!isInteractive) return;
if (allowClear && next === value) {
onChange(0);
} else {
onChange(next);
}
}
function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>){
if (!isInteractive) return;
const key = e.key;
if (key === 'ArrowRight' || key === 'ArrowUp'){
e.preventDefault();
const next = Math.min(max, (value || 0) + 1);
commit(next);
buttonsRef.current[next-1]?.focus();
} else if (key === 'ArrowLeft' || key === 'ArrowDown'){
e.preventDefault();
const next = Math.max(0, (value || 0) - 1);
commit(next);
if (next === 0) buttonsRef.current[0]?.focus(); else buttonsRef.current[next-1]?.focus();
} else if (key === 'Home'){
e.preventDefault();
commit(0);
buttonsRef.current[0]?.focus();
} else if (key === 'End'){
e.preventDefault();
commit(max);
buttonsRef.current[max-1]?.focus();
} else if (key === '0' || key === 'Backspace' || key === 'Delete'){
e.preventDefault();
commit(0);
buttonsRef.current[0]?.focus();
}
}
return (
<div
className={className}
role="radiogroup"
aria-label={ariaLabel}
aria-readonly={readOnly || undefined}
onKeyDown={handleKeyDown}
>
<div className="flex items-center">
{stars.map((n, idx) => {
const active = n <= (display || 0);
return (
<button
key={n}
type="button"
ref={(el)=> (buttonsRef.current[idx] = el)}
role="radio"
aria-checked={value === n}
aria-label={`${n} star${n>1 ? 's' : ''}`}
disabled={!isInteractive}
className={`p-0.5 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 ${!isInteractive ? 'cursor-default opacity-75' : ''}`}
onMouseEnter={()=> isInteractive && setHover(n)}
onMouseLeave={()=> isInteractive && setHover(null)}
onFocus={()=> isInteractive && setHover(n)}
onBlur={()=> isInteractive && setHover(null)}
onClick={()=> commit(n)}
>
<Star
size={size}
className={active ? 'text-yellow-500' : 'text-zinc-400'}
fill={active ? 'currentColor' : 'none'}
/>
</button>
);
})}
</div>
</div>
);
}
/* USAGE (example)
--------------------------------------------------
import { InteractiveStars } from "@/ui/shared/InteractiveStars";
function Example(){
const [rating, setRating] = React.useState(0);
return (
<InteractiveStars value={rating} onChange={setRating} />
);
}
// To wire into LongboxItemCard:
// 1) import { InteractiveStars } from "@/ui/shared/InteractiveStars";
// 2) Replace <RatingStars .../> with:
// <InteractiveStars value={myRating || Math.round(avgRating)} onChange={handleRate} size={14} />
// 3) Persist via onRate handler passed from parent (see earlier notes).
*/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment