This guide covers the animated article list feature that provides smooth, mobile-like animations for article updates.
The animated articles feature enhances the user experience by:
- Smoothly animating articles as they load initially
- Animating new articles as they appear (like phone notifications)
- Avoiding jarring full-page refreshes
- Providing visual feedback when articles are updated
The feature consists of:
-
AnimatedArticleList Component (
packages/app/src/components/app/animated-article-list.tsx)- Wraps article items with Motion animations
- Detects initial load vs. updates
- Handles new article animations
-
Smart Article Detection (
packages/app/src/routes/app/articles.tsx)- Tracks seen article IDs using a ref
- Detects new articles by comparing IDs
- Triggers animations for new articles only
-
Refresh Mechanisms
- Manual refresh uses
refetch()instead ofinvalidate() - Post-subscription refetch after 5-second delay
- All refreshes trigger smart merging
- Manual refresh uses
- Articles slide up from bottom with stagger effect
- Only first 20 articles are staggered for performance
- Uses spring animation:
{ type: "spring", stiffness: 300, damping: 30 } - Stagger delay: 0.05 seconds between items
- Articles pop in at top with scale + fade
- Uses spring animation for smooth feel
- No stagger (new articles are typically few)
- Animation:
scale: 0.8 → 1, opacity: 0 → 1, y: -20 → 0
Articles are detected as "new" by comparing their id (primary key) against a seenArticleIds Set:
const seenArticleIds = useRef<Set<number>>(new Set());
const newArticles = allArticles.filter(
(a) => !seenArticleIds.current.has(a.id)
);Why IDs instead of dates?
- IDs are unique and reliable
- No need to compare timestamps
- Simpler and more performant
- Works even if articles are reordered
The animated list works inside TabsContent components which use ResizeObserver for height animations:
- Animated list is a direct child of
TabsContent - No extra wrappers that could interfere with height calculations
- Animations don't cause layout shifts that break ResizeObserver
- Tab height animations continue to work correctly
import { AnimatedArticleList } from "@/components/app/animated-article-list";
<AnimatedArticleList articles={filteredArticles} newArticleIds={newArticleIds}>
{/* Infinite scroll trigger */}
<div ref={ref}>Loading more...</div>
</AnimatedArticleList>;articles: Article[]- Array of articles to displaynewArticleIds?: Set<number>- IDs of articles that should animate as "new"children?: React.ReactNode- Optional children (e.g., infinite scroll trigger)className?: string- Additional CSS classes
In your component:
const [newArticleIds, setNewArticleIds] = useState<Set<number>>(new Set());
const seenArticleIds = useRef<Set<number>>(new Set());
useEffect(() => {
const newArticles = allArticles.filter(
(a) => !seenArticleIds.current.has(a.id)
);
if (newArticles.length > 0) {
setNewArticleIds(new Set(newArticles.map((a) => a.id)));
newArticles.forEach((a) => seenArticleIds.current.add(a.id));
// Clear after animation completes
setTimeout(() => setNewArticleIds(new Set()), 3000);
}
}, [allArticles]);After subscribing to a new feed:
- Feed refresh is triggered immediately
- After 5 seconds, articles query is refetched
- New articles are detected and animated
- User sees smooth notification-like appearance
Implementation:
// In subscriptions.tsx
const timeoutId = setTimeout(() => {
queryClient.refetchQueries({
queryKey: [["trpc"], ["articles", "list"]],
});
toast.info("Checking for new articles...");
}, 5000);Rationale:
- Feed processing happens server-side and takes a few seconds
- One refetch after delay is cleaner than polling
- If articles don't appear, user can manually refresh
Manual refresh now uses refetch() instead of invalidate():
Before:
utils.articles.list.invalidate(); // Full resetAfter:
queryClient.refetchQueries({
queryKey: [["trpc"], ["articles", "list"]],
}); // Smart mergeThis allows:
- New articles to be detected and animated
- Existing articles to stay in place
- Smooth updates without full page refresh
Articles use React Query's infinite query pattern:
{
pages: [
{ items: Article[], total: number, hasMore: boolean }
],
pageParams: number[]
}The smart detection works with this structure by:
- Flattening pages:
allArticles = data?.pages.flatMap(page => page.items) - Comparing IDs across all pages
- Animating only truly new articles
For infinite queries, tRPC uses this key structure:
[
["trpc"],
["articles", "list"],
{ input }, // filters, limit, offset
"infinite",
];When refetching, use:
queryClient.refetchQueries({
queryKey: [["trpc"], ["articles", "list"]], // Partial match
});This refetches all article list queries (with different filters).
- Only first 20 articles are staggered on initial load
- Prevents performance issues with large lists
- Subsequent articles animate without stagger
- Uses Motion's hardware-accelerated animations
layoutprop enables smooth layout shiftsAnimatePresencehandles exit animations efficiently
seenArticleIdsref persists across renders (no re-initialization)- New article IDs cleared after 3 seconds
- No memory leaks from timeouts (proper cleanup)
Check:
- Are article IDs being tracked correctly?
- Is
newArticleIdsSet being passed to component? - Are articles actually new (not in seenArticleIds)?
Debug:
console.log("New articles:", newArticles);
console.log("Seen IDs:", Array.from(seenArticleIds.current));
console.log("New IDs:", Array.from(newArticleIds));Check:
- Is AnimatedArticleList a direct child of TabsContent?
- Are there extra wrapper divs?
- Do animations cause layout shifts?
Fix:
- Ensure no extra wrappers
- Check that animations use
layoutprop - Verify ResizeObserver is still working
Check:
- Is query key correct?
- Are you using
refetchQueries(notinvalidate)? - Is the query actually refetching?
Debug:
queryClient.refetchQueries({
queryKey: [["trpc"], ["articles", "list"]],
exact: false, // Partial match
});Check:
- Is
hasRenderedRefbeing reset incorrectly? - Are articles array changing identity on each render?
Fix:
- Ensure ref persists across renders
- Use stable article references
Tests mock Motion components to test logic without animation implementation:
vi.mock("motion/react", () => ({
motion: { div: ({ children, ...props }) => <div {...props}>{children}</div> },
AnimatePresence: ({ children }) => <div>{children}</div>,
}));Test Coverage:
- Component rendering
- Article ID tracking
- New article detection
- Children preservation
- className application
Potential improvements:
- Optimistic updates for article state changes
- More sophisticated merge logic for reordered articles
- Configurable animation timings
- Reduced motion support (prefers-reduced-motion)
- Virtual scrolling for very large lists