Add electric flash + fade animation for draft picks, fixes #381

When a pick is made the picked player's row flashes teal (--electric)
then fades out over 750ms. Works correctly with the TanStack Virtual
list by keeping the row in filteredParticipants during the animation
via animatingOutParticipantIds, then removing it after 650ms.

Key design decisions:
- Animation state lives in AvailableParticipantsSection (internal
  useState + timers); the draft room passes a pickAnimationSignal
  {id, seq} rather than the Set, so memo only breaks on each pick,
  not on the 650ms cleanup
- displayDrafted freezes row content (badges, buttons) during the
  animation to prevent height shifts
- Per-ID independent timers via Map ref support rapid autodraft picks
  running concurrently without cancellation
- Timers are cleared on unmount in both the draft room and component
- prefers-reduced-motion: skips the color flash, plain opacity fade only
- Removes "Draft Grid" heading from DraftGridSection (visual cleanup)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-05-10 23:11:04 -07:00
parent 02fa285585
commit a6f8ff62c4
6 changed files with 119 additions and 27 deletions

View file

@ -162,6 +162,41 @@ html {
animation: ticker-scroll 28s linear infinite;
}
@keyframes pick-flash-out {
0% { background-color: transparent; opacity: 1; }
35% { background-color: rgb(44 225 193 / 0.25); opacity: 1; }
60% { background-color: rgb(44 225 193 / 0.06); opacity: 1; }
75% { background-color: transparent; opacity: 1; }
100% { background-color: transparent; opacity: 0; }
}
@keyframes pick-flash-out-reduced {
0% { opacity: 1; }
100% { opacity: 0; }
}
.animate-pick-flash-out {
animation: pick-flash-out 750ms ease-in forwards;
}
@media (prefers-reduced-motion: reduce) {
.animate-pick-flash-out {
animation: pick-flash-out-reduced 750ms ease-in forwards;
}
}
@keyframes pick-flash {
0% { background-color: transparent; }
35% { background-color: rgb(44 225 193 / 0.25); }
75% { background-color: rgb(44 225 193 / 0.06); }
100% { background-color: transparent; }
}
.animate-pick-flash {
animation: pick-flash 750ms ease-in forwards;
}
@media (prefers-reduced-motion: reduce) {
.animate-pick-flash {
animation: none;
}
}
/* NProgress theme override */
#nprogress .bar {
background: var(--electric) !important;

View file

@ -57,6 +57,7 @@ const defaultProps = {
onToggleWatchlist: vi.fn(),
showOnlyWatched: false,
onShowOnlyWatchedChange: vi.fn(),
pickAnimationSignal: null,
participantRanks: new Map([
["1", { overallRank: 1, sportRank: 1 }],
["2", { overallRank: 2, sportRank: 1 }],

View file

@ -1,4 +1,4 @@
import { memo, useCallback, useMemo, useRef, useState } from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
@ -169,6 +169,7 @@ interface AvailableParticipantsSectionProps {
onToggleWatchlist: (participantId: string) => void;
showOnlyWatched: boolean;
onShowOnlyWatchedChange: (show: boolean) => void;
pickAnimationSignal: { id: string; seq: number } | null;
}
export const AvailableParticipantsSection = memo(function AvailableParticipantsSection({
@ -200,7 +201,28 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
onToggleWatchlist,
showOnlyWatched,
onShowOnlyWatchedChange,
pickAnimationSignal,
}: AvailableParticipantsSectionProps) {
const [animatingOutParticipantIds, setAnimatingOutParticipantIds] = useState<Set<string>>(new Set());
const animationTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
useEffect(() => {
if (!pickAnimationSignal) return;
const { id } = pickAnimationSignal;
const existing = animationTimersRef.current.get(id);
if (existing) clearTimeout(existing);
setAnimatingOutParticipantIds((prev) => new Set([...prev, id]));
const timer = setTimeout(() => {
setAnimatingOutParticipantIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
animationTimersRef.current.delete(id);
}, 650);
animationTimersRef.current.set(id, timer);
}, [pickAnimationSignal]);
useEffect(() => () => { animationTimersRef.current.forEach(clearTimeout); }, []);
const queueMap = useMemo(
() => new Map(queue.map((item) => [item.participantId, item.id])),
[queue]
@ -501,6 +523,12 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
getParticipantState(participant, draftedParticipantIds, queueMap, eligibility, watchedParticipantIds);
const rank = participantRanks.get(participant.id);
const followsDivider = listItems[virtualItem.index - 1]?.type === "divider";
const isAnimatingOut = animatingOutParticipantIds.has(participant.id);
const animationClass = isAnimatingOut
? (hideDrafted ? "animate-pick-flash-out" : "animate-pick-flash")
: "";
// Freeze display state while animating so content doesn't shift height
const displayDrafted = isDrafted && !isAnimatingOut;
return (
<div
@ -514,10 +542,11 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
right: 0,
}}
>
<div className="md:hidden px-4 py-1">
<div
<div className={animationClass}>
<div className="md:hidden px-4 py-1">
<div
className={`bg-card border rounded-lg p-3 flex items-start justify-between gap-3 transition-colors ${
isDrafted
displayDrafted
? "bg-muted/50 opacity-60"
: !isEligible
? "bg-destructive/10 opacity-75"
@ -528,7 +557,7 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
>
<div className="flex flex-col gap-1 min-w-0">
<span
className={`font-semibold text-sm truncate ${!isEligible && !isDrafted ? "text-muted-foreground" : ""}`}
className={`font-semibold text-sm truncate ${!isEligible && !displayDrafted ? "text-muted-foreground" : ""}`}
>
{participant.name}
</span>
@ -539,12 +568,12 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
<span className="text-xs text-muted-foreground tabular-nums">
OVR {rank?.overallRank} · SPR {rank?.sportRank}
</span>
{isDrafted && (
{displayDrafted && (
<Badge variant="secondary" className="text-xs">
Drafted
</Badge>
)}
{!isDrafted && !isEligible && (
{!displayDrafted && !isEligible && (
<Badge
variant="destructive"
className="text-xs"
@ -553,19 +582,19 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
Ineligible
</Badge>
)}
{!isDrafted && !isEligible && ineligibleReason && (
{!displayDrafted && !isEligible && ineligibleReason && (
<p className="text-xs text-destructive/90">
{ineligibleReason.message}
</p>
)}
{isInQueue && !isDrafted && isEligible && (
{isInQueue && !displayDrafted && isEligible && (
<Badge variant="default" className="text-xs">
Queued
</Badge>
)}
</div>
</div>
{hasTeam && !isDrafted && (
{hasTeam && !displayDrafted && (
<div className="flex gap-2 flex-shrink-0">
<Button
variant="ghost"
@ -622,13 +651,13 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
</div>
)}
</div>
</div>
</div>{/* md:hidden */}
<div
className={`hidden md:grid ${desktopGridClass} items-center px-4 transition-colors ${
<div
className={`hidden md:grid ${desktopGridClass} items-center px-4 transition-colors ${
followsDivider ? "" : "border-t"
} ${
isDrafted
displayDrafted
? "bg-muted/50 opacity-60"
: !isEligible
? "bg-destructive/10 opacity-75"
@ -637,7 +666,7 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
: "hover:bg-muted/50"
}`}
title={
!isEligible && !isDrafted ? ineligibleReason?.message : undefined
!isEligible && !displayDrafted ? ineligibleReason?.message : undefined
}
>
<span className="text-center text-muted-foreground tabular-nums p-3 px-0">
@ -649,19 +678,19 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
<div className="p-3">
<div className="flex items-center gap-2">
<span
className={`font-medium ${!isEligible && !isDrafted ? "text-muted-foreground" : ""}`}
className={`font-medium ${!isEligible && !displayDrafted ? "text-muted-foreground" : ""}`}
>
{participant.name}
</span>
<Badge variant="outline" className="text-xs">
{participant.sport.name}
</Badge>
{isDrafted && (
{displayDrafted && (
<Badge variant="secondary" className="text-xs">
Drafted
</Badge>
)}
{!isDrafted && !isEligible && (
{!displayDrafted && !isEligible && (
<Badge
variant="destructive"
className="text-xs"
@ -670,12 +699,12 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
Ineligible
</Badge>
)}
{isInQueue && !isDrafted && isEligible && (
{isInQueue && !displayDrafted && isEligible && (
<Badge variant="default" className="text-xs">
Queued
</Badge>
)}
{!isDrafted && !isEligible && ineligibleReason && (
{!displayDrafted && !isEligible && ineligibleReason && (
<p className="mt-1 text-xs text-destructive/90">
{ineligibleReason.message}
</p>
@ -685,7 +714,7 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
{hasTeam && (
<div className="p-3">
<div className="flex gap-2 justify-end items-center">
{!isDrafted && (
{!displayDrafted && (
<>
<Button
variant="ghost"
@ -744,7 +773,8 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
</div>
</div>
)}
</div>
</div>{/* hidden md:grid */}
</div>{/* animationClass wrapper */}
</div>
);
})}

View file

@ -100,7 +100,6 @@ export const DraftGridSection = memo(function DraftGridSection({
return (
<div className="h-full flex flex-col p-4">
<h2 className="text-xl font-semibold mb-4 flex-shrink-0">Draft Grid</h2>
<div className="flex-1 overflow-auto">
<div className="inline-block min-w-full min-h-full">
{/* Team Headers */}

View file

@ -42,6 +42,7 @@ interface UseDraftSocketEventsParams {
setRoomClosed: (value: boolean) => void;
setIsSyncing: (value: boolean) => void;
setBracktVorps: (fn: (prev: Map<string, number>) => Map<string, number>) => void;
setAnimatingOutParticipantId: (id: string) => void;
}
export function useDraftSocketEvents({
@ -72,6 +73,7 @@ export function useDraftSocketEvents({
setRoomClosed,
setIsSyncing,
setBracktVorps,
setAnimatingOutParticipantId,
}: UseDraftSocketEventsParams) {
useEffect(() => {
type PickMadePayload = {
@ -85,6 +87,9 @@ export function useDraftSocketEvents({
} else {
setPicks((prev) => [...prev, data.pick]);
setCurrentPick(data.nextPickNumber);
if (data.pick?.participant?.id) {
setAnimatingOutParticipantId(data.pick.participant.id);
}
}
if (data.isDraftComplete) return;

View file

@ -468,6 +468,26 @@ export default function DraftRoom() {
);
}, [replacePickSlot, transformedPicks, transformedParticipants, seasonSportsData, season]);
const [animatingOutParticipantIds, setAnimatingOutParticipantIds] = useState<Set<string>>(new Set());
const [pickAnimationSignal, setPickAnimationSignal] = useState<{ id: string; seq: number } | null>(null);
const pickSeqRef = useRef(0);
const animationTimersRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
const addAnimatingOut = useCallback((id: string) => {
setAnimatingOutParticipantIds((prev) => new Set([...prev, id]));
pickSeqRef.current += 1;
setPickAnimationSignal({ id, seq: pickSeqRef.current });
const timer = setTimeout(() => {
setAnimatingOutParticipantIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
animationTimersRef.current.delete(timer);
}, 650);
animationTimersRef.current.add(timer);
}, []);
useEffect(() => () => { animationTimersRef.current.forEach(clearTimeout); }, []);
useDraftSocketEvents({
on,
off,
@ -496,6 +516,7 @@ export default function DraftRoom() {
setRoomClosed,
setIsSyncing,
setBracktVorps,
setAnimatingOutParticipantId: addAnimatingOut,
});
// Persist sidebar collapsed state
@ -1088,8 +1109,8 @@ export default function DraftRoom() {
const filteredParticipants = useMemo(() => {
const sportFilterSet = new Set(sportFilters);
return sortedAvailableParticipants.filter((participant) => {
// Drafted filter - hide drafted participants by default
if (hideDrafted && draftedParticipantIds.has(participant.id)) {
// Drafted filter - hide drafted participants by default, but keep animating-out rows visible
if (hideDrafted && draftedParticipantIds.has(participant.id) && !animatingOutParticipantIds.has(participant.id)) {
return false;
}
// Eligibility filter - hide ineligible participants by default (if user has a team)
@ -1120,7 +1141,7 @@ export default function DraftRoom() {
}
return true;
});
}, [sortedAvailableParticipants, hideDrafted, hideIneligible, hideCompletedSports, eligibility, draftedParticipantIds, userDraftedSportIds, searchQuery, sportFilters, showOnlyWatched, watchedParticipantIds]);
}, [sortedAvailableParticipants, hideDrafted, hideIneligible, hideCompletedSports, eligibility, draftedParticipantIds, animatingOutParticipantIds, userDraftedSportIds, searchQuery, sportFilters, showOnlyWatched, watchedParticipantIds]);
// Shared component props — defined once to avoid duplication between desktop and mobile layouts
const handleHideCompletedSportsChange = useCallback((hide: boolean) => {
@ -1179,7 +1200,8 @@ export default function DraftRoom() {
onToggleWatchlist: handleToggleWatchlist,
showOnlyWatched,
onShowOnlyWatchedChange: setShowOnlyWatched,
}), [filteredParticipants, participantRanks, miniDraftGrid, searchQuery, sportFilters, hideDrafted, hideIneligible, uniqueSports, draftedParticipantIds, queue, eligibility, canPick, userTeam, hideCompletedSports, userDraftedSportNames, handleHideCompletedSportsChange, handleMakePick, handleAddToQueue, handleRemoveFromQueue, setSearchQuery, setSportFilters, setHideDrafted, setHideIneligible, projectedPicks, watchedParticipantIds, handleToggleWatchlist, showOnlyWatched, setShowOnlyWatched]);
pickAnimationSignal,
}), [filteredParticipants, participantRanks, miniDraftGrid, searchQuery, sportFilters, hideDrafted, hideIneligible, uniqueSports, draftedParticipantIds, queue, eligibility, canPick, userTeam, hideCompletedSports, userDraftedSportNames, handleHideCompletedSportsChange, handleMakePick, handleAddToQueue, handleRemoveFromQueue, setSearchQuery, setSportFilters, setHideDrafted, setHideIneligible, projectedPicks, watchedParticipantIds, handleToggleWatchlist, showOnlyWatched, setShowOnlyWatched, pickAnimationSignal]);
const draftGridSectionProps = {
draftSlots,