From 190a6e1fe77ae1a2deabba75cc5934d684036758 Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Sun, 10 May 2026 23:17:29 -0700 Subject: [PATCH] Add electric flash + fade animation for draft picks, fixes #381 (#407) 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 --- app/app.css | 35 +++++++++ .../AvailableParticipantsSection.test.tsx | 1 + .../draft/AvailableParticipantsSection.tsx | 74 +++++++++++++------ app/components/draft/DraftGridSection.tsx | 1 - app/hooks/useDraftSocketEvents.ts | 5 ++ .../leagues/$leagueId.draft.$seasonId.tsx | 30 +++++++- 6 files changed, 119 insertions(+), 27 deletions(-) diff --git a/app/app.css b/app/app.css index 2af3b25..531d075 100644 --- a/app/app.css +++ b/app/app.css @@ -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; diff --git a/app/components/__tests__/AvailableParticipantsSection.test.tsx b/app/components/__tests__/AvailableParticipantsSection.test.tsx index 8230fb9..bcf5534 100644 --- a/app/components/__tests__/AvailableParticipantsSection.test.tsx +++ b/app/components/__tests__/AvailableParticipantsSection.test.tsx @@ -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 }], diff --git a/app/components/draft/AvailableParticipantsSection.tsx b/app/components/draft/AvailableParticipantsSection.tsx index 9280a25..dbe8d78 100644 --- a/app/components/draft/AvailableParticipantsSection.tsx +++ b/app/components/draft/AvailableParticipantsSection.tsx @@ -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>(new Set()); + const animationTimersRef = useRef>>(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 (
-
-
+
+
{participant.name} @@ -539,12 +568,12 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS OVR {rank?.overallRank} · SPR {rank?.sportRank} - {isDrafted && ( + {displayDrafted && ( Drafted )} - {!isDrafted && !isEligible && ( + {!displayDrafted && !isEligible && ( )} - {!isDrafted && !isEligible && ineligibleReason && ( + {!displayDrafted && !isEligible && ineligibleReason && (

{ineligibleReason.message}

)} - {isInQueue && !isDrafted && isEligible && ( + {isInQueue && !displayDrafted && isEligible && ( Queued )}
- {hasTeam && !isDrafted && ( + {hasTeam && !displayDrafted && (
-
+
{/* md:hidden */} -
@@ -649,19 +678,19 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
{participant.name} {participant.sport.name} - {isDrafted && ( + {displayDrafted && ( Drafted )} - {!isDrafted && !isEligible && ( + {!displayDrafted && !isEligible && ( )} - {isInQueue && !isDrafted && isEligible && ( + {isInQueue && !displayDrafted && isEligible && ( Queued )} - {!isDrafted && !isEligible && ineligibleReason && ( + {!displayDrafted && !isEligible && ineligibleReason && (

{ineligibleReason.message}

@@ -685,7 +714,7 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS {hasTeam && (
- {!isDrafted && ( + {!displayDrafted && ( <>
)} -
+
{/* hidden md:grid */} +
{/* animationClass wrapper */}
); })} diff --git a/app/components/draft/DraftGridSection.tsx b/app/components/draft/DraftGridSection.tsx index 0a74120..9c990f0 100644 --- a/app/components/draft/DraftGridSection.tsx +++ b/app/components/draft/DraftGridSection.tsx @@ -100,7 +100,6 @@ export const DraftGridSection = memo(function DraftGridSection({ return (
-

Draft Grid

{/* Team Headers */} diff --git a/app/hooks/useDraftSocketEvents.ts b/app/hooks/useDraftSocketEvents.ts index 489ef93..af94f4a 100644 --- a/app/hooks/useDraftSocketEvents.ts +++ b/app/hooks/useDraftSocketEvents.ts @@ -42,6 +42,7 @@ interface UseDraftSocketEventsParams { setRoomClosed: (value: boolean) => void; setIsSyncing: (value: boolean) => void; setBracktVorps: (fn: (prev: Map) => Map) => 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; diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index 0ff2c92..9ca27aa 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -468,6 +468,26 @@ export default function DraftRoom() { ); }, [replacePickSlot, transformedPicks, transformedParticipants, seasonSportsData, season]); + const [animatingOutParticipantIds, setAnimatingOutParticipantIds] = useState>(new Set()); + const [pickAnimationSignal, setPickAnimationSignal] = useState<{ id: string; seq: number } | null>(null); + const pickSeqRef = useRef(0); + const animationTimersRef = useRef>>(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,