A star rating separates the value a pointer is previewing from the value a user has committed. Build a five-star React control that previews half-star values, commits a value on click, restores the committed value on pointer leave, and remains operable from a keyboard.
Implement the interaction logic in the default App component in App.tsx. The starter already renders the visual shell and exposes one focusable slider control.
2.5 / 5 without changing the committed rating.2.5 / 5 remains because the click committed it.0.5 per press. Home clears it to 0; End commits 5.rating is committed; hover is temporary. Render hover || rating.rating. Pointer leave clears only hover.nativeEvent.offsetX with half of currentTarget.offsetWidth.0.5, always within 0..5.preventDefault() for handled slider keys and clear hover before committing the keyboard result.role="slider", its ARIA value attributes, the focus target, and the polite text status in sync with the committed rating.styles.css provide the clipped gold overlay and visible keyboard focus ring.You can make preview and commitment coexist by storing them separately, then deriving every visible fill from whichever value currently has priority.
Pointer movement is temporary: it should answer “what would I select here?” A click or keyboard press is durable: it answers “what did I select?” If both interactions write the same state, leaving the stars either erases the choice or leaves the last preview committed by accident.
Keep rating as the committed source and hover as a short-lived overlay. hover || rating selects the overlay only while it is non-zero. Pointer leave removes that overlay; keyboard input writes the committed source directly and clears any stale preview.
One state value looks sufficient at first:
const [rating, setRating] = useState(0);
<div onMouseLeave={() => setRating(0)}>
<span onMouseMove={() => setRating(2.5)} />
</div>
The hover preview appears, but leaving must reset that same state, so it also destroys a clicked rating. Removing the reset creates the opposite bug: merely hovering commits a value. Separate state gives temporary and durable updates different destinations.
import { useState } from 'react';
import './styles.css';
const STARS = [0, 1, 2, 3, 4];
export default function App() {
const [rating, setRating] = useState(0);
const [hover, setHover] = useState(0);
const value = hover || rating;
function valueAt(event: React.MouseEvent<HTMLSpanElement>, index: number) {
const half = event.nativeEvent.offsetX < event.currentTarget.offsetWidth / 2 ? 0.5 : 1;
return index + half;
}
function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
let next: number | undefined;
if (event.key === 'ArrowRight' || event.key === 'ArrowUp') {
next = Math.min(5, rating + 0.5);
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') {
next = Math.max(0, rating - 0.5);
} else if (event.key === 'Home') {
next = 0;
} else if (event.key === 'End') {
next = 5;
}
if (next === undefined) return;
event.preventDefault();
setHover(0);
setRating(next);
}
return (
<main className="container">
<h1>Star Rating</h1>
<div
className="stars"
role="slider"
tabIndex={0}
aria-label="Rating"
aria-valuemin={0}
aria-valuemax={5}
aria-valuenow={rating}
aria-valuetext={rating ? `${rating} out of 5 stars` : 'No rating'}
onMouseLeave={() => setHover(0)}
onKeyDown={onKeyDown}
>
{STARS.map((index) => (
<span
key={index}
className="star"
aria-hidden="true"
onMouseMove={(event) => setHover(valueAt(event, index))}
onClick={(event) => setRating(valueAt(event, index))}
>
<span className="star-empty">☆</span>
<span
className="star-fill"
style={{ width: `${Math.max(0, Math.min(1, value - index)) * 100}%` }}
>
★
</span>
</span>
))}
</div>
<p className="value" aria-live="polite">
{value ? `${value} / 5` : 'No rating'}
</p>
</main>
);
}
valueAt converts the pointer's position inside one star into either its half or whole value. The fill expression clamps value - index to 0..1, so a displayed 2.5 produces widths 100, 100, 50, 0, 0. The focusable row uses slider semantics because it represents one fractional numeric value, while the decorative star glyphs stay hidden from the accessibility tree.
Start with a committed rating of 2.5. Moving over the right half of star index 3 calls setHover(4), so the visible value becomes 4 while aria-valuenow remains the committed 2.5. Leaving calls setHover(0) and restores 2.5. Pressing ArrowRight then computes Math.min(5, 2.5 + 0.5), prevents scrolling, clears any hover, and commits 3.
target — nested glyphs can become the event target. Use currentTarget for the star whose width you measure.aria-valuenow should track rating; the live text can announce the temporary visual preview.max and step props, then derive the star array and keyboard increment from them.A reducer separates temporary hover from committed input while one derived value drives every fill width.
import { useReducer } from 'react'; import './styles.css';
const stars = [0, 1, 2, 3, 4]; type State = { rating: number; hover: number }; type Action = { type: 'rate'; value: number } | { type: 'hover'; value: number };
function reducer(state: State, action: Action): State { return action.type === 'rate' ? { rating: action.value, hover: 0 } : { ...state, hover: action.value }; }
function step(key: string, rating: number) { if (key === 'ArrowRight' || key === 'ArrowUp') return Math.min(5, rating + 0.5); if (key === 'ArrowLeft' || key === 'ArrowDown') return Math.max(0, rating - 0.5); if (key === 'Home') return 0; if (key === 'End') return 5; }
export default function App() { const [state, dispatch] = useReducer(reducer, { rating: 0, hover: 0 }); const value = state.hover || state.rating; const at = (event: React.MouseEvent<HTMLSpanElement>, index: number) => index + (event.nativeEvent.offsetX < event.currentTarget.offsetWidth / 2 ? 0.5 : 1); const key = (event: React.KeyboardEvent<HTMLDivElement>) => { const value = step(event.key, state.rating); if (value === undefined) return; event.preventDefault(); dispatch({ type: 'rate', value }); }; return <main className="container"><h1>Star Rating</h1><div className="stars" role="slider" tabIndex={0} aria-label="Rating" aria-valuemin={0} aria-valuemax={5} aria-valuenow={state.rating} aria-valuetext={state.rating ? `${state.rating} out of 5 stars` : 'No rating'} onMouseLeave={() => dispatch({ type: 'hover', value: 0 })} onKeyDown={key}>{stars.map((index) => <span key={index} className="star" aria-hidden="true" onMouseMove={(event) => dispatch({ type: 'hover', value: at(event, index) })} onClick={(event) => dispatch({ type: 'rate', value: at(event, index) })}><span className="star-empty">☆</span><span className="star-fill" style={{ width: `${Math.max(0, Math.min(1, value - index)) * 100}%` }}>★</span></span>)}</div><p className="value" aria-live="polite">{value ? `${value} / 5` : 'No rating'}</p></main>; }A custom hook exposes the committed value, display value, and shared event commands to the presentation.
import { useState } from 'react'; import './styles.css'; const stars = [0, 1, 2, 3, 4];
function useRating() { const [rating, setRating] = useState(0), [hover, setHover] = useState(0); const choose = (event: React.MouseEvent<HTMLSpanElement>, index: number) => index + (event.nativeEvent.offsetX < event.currentTarget.offsetWidth / 2 ? 0.5 : 1); const key = (event: React.KeyboardEvent<HTMLDivElement>) => { const deltas: Record<string, number> = { ArrowRight: 0.5, ArrowUp: 0.5, ArrowLeft: -0.5, ArrowDown: -0.5 }; const next = deltas[event.key] === undefined ? event.key === 'Home' ? 0 : event.key === 'End' ? 5 : undefined : Math.max(0, Math.min(5, rating + deltas[event.key])); if (next === undefined) return; event.preventDefault(); setHover(0); setRating(next); }; return { rating, value: hover || rating, setHover, setRating, choose, key }; }
export default function App() { const model = useRating(); return <main className="container"><h1>Star Rating</h1><div className="stars" role="slider" tabIndex={0} aria-label="Rating" aria-valuemin={0} aria-valuemax={5} aria-valuenow={model.rating} aria-valuetext={model.rating ? `${model.rating} out of 5 stars` : 'No rating'} onMouseLeave={() => model.setHover(0)} onKeyDown={model.key}>{stars.map((index) => <span key={index} className="star" aria-hidden="true" onMouseMove={(event) => model.setHover(model.choose(event, index))} onClick={(event) => model.setRating(model.choose(event, index))}><span className="star-empty">☆</span><span className="star-fill" style={{ width: `${Math.max(0, Math.min(1, model.value - index)) * 100}%` }}>★</span></span>)}</div><p className="value" aria-live="polite">{model.value ? `${model.value} / 5` : 'No rating'}</p></main>; }Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
A star rating separates the value a pointer is previewing from the value a user has committed. Build a five-star React control that previews half-star values, commits a value on click, restores the committed value on pointer leave, and remains operable from a keyboard.
Implement the interaction logic in the default App component in App.tsx. The starter already renders the visual shell and exposes one focusable slider control.
2.5 / 5 without changing the committed rating.2.5 / 5 remains because the click committed it.0.5 per press. Home clears it to 0; End commits 5.rating is committed; hover is temporary. Render hover || rating.rating. Pointer leave clears only hover.nativeEvent.offsetX with half of currentTarget.offsetWidth.0.5, always within 0..5.preventDefault() for handled slider keys and clear hover before committing the keyboard result.role="slider", its ARIA value attributes, the focus target, and the polite text status in sync with the committed rating.styles.css provide the clipped gold overlay and visible keyboard focus ring.You can make preview and commitment coexist by storing them separately, then deriving every visible fill from whichever value currently has priority.
Pointer movement is temporary: it should answer “what would I select here?” A click or keyboard press is durable: it answers “what did I select?” If both interactions write the same state, leaving the stars either erases the choice or leaves the last preview committed by accident.
Keep rating as the committed source and hover as a short-lived overlay. hover || rating selects the overlay only while it is non-zero. Pointer leave removes that overlay; keyboard input writes the committed source directly and clears any stale preview.
One state value looks sufficient at first:
const [rating, setRating] = useState(0);
<div onMouseLeave={() => setRating(0)}>
<span onMouseMove={() => setRating(2.5)} />
</div>
The hover preview appears, but leaving must reset that same state, so it also destroys a clicked rating. Removing the reset creates the opposite bug: merely hovering commits a value. Separate state gives temporary and durable updates different destinations.
import { useState } from 'react';
import './styles.css';
const STARS = [0, 1, 2, 3, 4];
export default function App() {
const [rating, setRating] = useState(0);
const [hover, setHover] = useState(0);
const value = hover || rating;
function valueAt(event: React.MouseEvent<HTMLSpanElement>, index: number) {
const half = event.nativeEvent.offsetX < event.currentTarget.offsetWidth / 2 ? 0.5 : 1;
return index + half;
}
function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
let next: number | undefined;
if (event.key === 'ArrowRight' || event.key === 'ArrowUp') {
next = Math.min(5, rating + 0.5);
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') {
next = Math.max(0, rating - 0.5);
} else if (event.key === 'Home') {
next = 0;
} else if (event.key === 'End') {
next = 5;
}
if (next === undefined) return;
event.preventDefault();
setHover(0);
setRating(next);
}
return (
<main className="container">
<h1>Star Rating</h1>
<div
className="stars"
role="slider"
tabIndex={0}
aria-label="Rating"
aria-valuemin={0}
aria-valuemax={5}
aria-valuenow={rating}
aria-valuetext={rating ? `${rating} out of 5 stars` : 'No rating'}
onMouseLeave={() => setHover(0)}
onKeyDown={onKeyDown}
>
{STARS.map((index) => (
<span
key={index}
className="star"
aria-hidden="true"
onMouseMove={(event) => setHover(valueAt(event, index))}
onClick={(event) => setRating(valueAt(event, index))}
>
<span className="star-empty">☆</span>
<span
className="star-fill"
style={{ width: `${Math.max(0, Math.min(1, value - index)) * 100}%` }}
>
★
</span>
</span>
))}
</div>
<p className="value" aria-live="polite">
{value ? `${value} / 5` : 'No rating'}
</p>
</main>
);
}
valueAt converts the pointer's position inside one star into either its half or whole value. The fill expression clamps value - index to 0..1, so a displayed 2.5 produces widths 100, 100, 50, 0, 0. The focusable row uses slider semantics because it represents one fractional numeric value, while the decorative star glyphs stay hidden from the accessibility tree.
Start with a committed rating of 2.5. Moving over the right half of star index 3 calls setHover(4), so the visible value becomes 4 while aria-valuenow remains the committed 2.5. Leaving calls setHover(0) and restores 2.5. Pressing ArrowRight then computes Math.min(5, 2.5 + 0.5), prevents scrolling, clears any hover, and commits 3.
target — nested glyphs can become the event target. Use currentTarget for the star whose width you measure.aria-valuenow should track rating; the live text can announce the temporary visual preview.max and step props, then derive the star array and keyboard increment from them.A reducer separates temporary hover from committed input while one derived value drives every fill width.
import { useReducer } from 'react'; import './styles.css';
const stars = [0, 1, 2, 3, 4]; type State = { rating: number; hover: number }; type Action = { type: 'rate'; value: number } | { type: 'hover'; value: number };
function reducer(state: State, action: Action): State { return action.type === 'rate' ? { rating: action.value, hover: 0 } : { ...state, hover: action.value }; }
function step(key: string, rating: number) { if (key === 'ArrowRight' || key === 'ArrowUp') return Math.min(5, rating + 0.5); if (key === 'ArrowLeft' || key === 'ArrowDown') return Math.max(0, rating - 0.5); if (key === 'Home') return 0; if (key === 'End') return 5; }
export default function App() { const [state, dispatch] = useReducer(reducer, { rating: 0, hover: 0 }); const value = state.hover || state.rating; const at = (event: React.MouseEvent<HTMLSpanElement>, index: number) => index + (event.nativeEvent.offsetX < event.currentTarget.offsetWidth / 2 ? 0.5 : 1); const key = (event: React.KeyboardEvent<HTMLDivElement>) => { const value = step(event.key, state.rating); if (value === undefined) return; event.preventDefault(); dispatch({ type: 'rate', value }); }; return <main className="container"><h1>Star Rating</h1><div className="stars" role="slider" tabIndex={0} aria-label="Rating" aria-valuemin={0} aria-valuemax={5} aria-valuenow={state.rating} aria-valuetext={state.rating ? `${state.rating} out of 5 stars` : 'No rating'} onMouseLeave={() => dispatch({ type: 'hover', value: 0 })} onKeyDown={key}>{stars.map((index) => <span key={index} className="star" aria-hidden="true" onMouseMove={(event) => dispatch({ type: 'hover', value: at(event, index) })} onClick={(event) => dispatch({ type: 'rate', value: at(event, index) })}><span className="star-empty">☆</span><span className="star-fill" style={{ width: `${Math.max(0, Math.min(1, value - index)) * 100}%` }}>★</span></span>)}</div><p className="value" aria-live="polite">{value ? `${value} / 5` : 'No rating'}</p></main>; }A custom hook exposes the committed value, display value, and shared event commands to the presentation.
import { useState } from 'react'; import './styles.css'; const stars = [0, 1, 2, 3, 4];
function useRating() { const [rating, setRating] = useState(0), [hover, setHover] = useState(0); const choose = (event: React.MouseEvent<HTMLSpanElement>, index: number) => index + (event.nativeEvent.offsetX < event.currentTarget.offsetWidth / 2 ? 0.5 : 1); const key = (event: React.KeyboardEvent<HTMLDivElement>) => { const deltas: Record<string, number> = { ArrowRight: 0.5, ArrowUp: 0.5, ArrowLeft: -0.5, ArrowDown: -0.5 }; const next = deltas[event.key] === undefined ? event.key === 'Home' ? 0 : event.key === 'End' ? 5 : undefined : Math.max(0, Math.min(5, rating + deltas[event.key])); if (next === undefined) return; event.preventDefault(); setHover(0); setRating(next); }; return { rating, value: hover || rating, setHover, setRating, choose, key }; }
export default function App() { const model = useRating(); return <main className="container"><h1>Star Rating</h1><div className="stars" role="slider" tabIndex={0} aria-label="Rating" aria-valuemin={0} aria-valuemax={5} aria-valuenow={model.rating} aria-valuetext={model.rating ? `${model.rating} out of 5 stars` : 'No rating'} onMouseLeave={() => model.setHover(0)} onKeyDown={model.key}>{stars.map((index) => <span key={index} className="star" aria-hidden="true" onMouseMove={(event) => model.setHover(model.choose(event, index))} onClick={(event) => model.setRating(model.choose(event, index))}><span className="star-empty">☆</span><span className="star-fill" style={{ width: `${Math.max(0, Math.min(1, model.value - index)) * 100}%` }}>★</span></span>)}</div><p className="value" aria-live="polite">{model.value ? `${model.value} / 5` : 'No rating'}</p></main>; }Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.