News Feed

PreviousNext

Interactive news feed with categories, search, and animated cards

Docs
uitripledcomponent

Preview

Loading preview…
components/components/news-feed/news-feed.tsx
"use client";

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { AnimatePresence, motion } from "framer-motion";
import {
  ArrowRight,
  Bookmark,
  Clock,
  Filter,
  MessageSquare,
  MoreHorizontal,
  Newspaper,
  Search,
  Share2,
  TrendingUp,
} from "lucide-react";
import { useState } from "react";

// ============================================================================
// TYPES
// ============================================================================

interface NewsItem {
  id: string;
  title: string;
  summary: string;
  category: "Technology" | "Design" | "Business" | "AI" | "Crypto";
  author: {
    name: string;
    avatar: string;
    role: string;
  };
  publishedAt: string;
  readTime: string;
  likes: number;
  comments: number;
  trending?: boolean;
  image?: string;
}

// ============================================================================
// DUMMY DATA
// ============================================================================

const NEWS_ITEMS: NewsItem[] = [
  {
    id: "1",
    title: "The Future of AI in Modern Interface Design",
    summary:
      "How artificial intelligence is reshaping the way we interact with digital products and the role of designers in this new era.",
    category: "AI",
    author: {
      name: "Sarah Chen",
      avatar: "https://i.pravatar.cc/150?u=sarah",
      role: "Product Designer",
    },
    publishedAt: "2h ago",
    readTime: "5 min read",
    likes: 1240,
    comments: 86,
    trending: true,
    image:
      "https://images.unsplash.com/photo-1677442136019-21780ecad995?auto=format&fit=crop&q=80&w=1000",
  },
  {
    id: "2",
    title: "WebAssembly: The Next Big Thing in Web Development?",
    summary:
      "Exploring the potential of WebAssembly to bring desktop-class performance to the web browser.",
    category: "Technology",
    author: {
      name: "Alex Rivera",
      avatar: "https://i.pravatar.cc/150?u=alex",
      role: "Senior Dev",
    },
    publishedAt: "4h ago",
    readTime: "8 min read",
    likes: 856,
    comments: 42,
  },
  {
    id: "3",
    title: "Sustainable Tech: Building Green Software",
    summary:
      "Why energy efficiency in code matters and how developers can contribute to a more sustainable future.",
    category: "Technology",
    author: {
      name: "Emma Wilson",
      avatar: "https://i.pravatar.cc/150?u=emma",
      role: "Tech Lead",
    },
    publishedAt: "5h ago",
    readTime: "6 min read",
    likes: 623,
    comments: 28,
  },
  {
    id: "4",
    title: "Crypto Markets See Unprecedented Growth",
    summary:
      "Analysis of the latest market trends and what it means for institutional investors.",
    category: "Crypto",
    author: {
      name: "Michael Chang",
      avatar: "https://i.pravatar.cc/150?u=michael",
      role: "Financial Analyst",
    },
    publishedAt: "6h ago",
    readTime: "4 min read",
    likes: 2100,
    comments: 154,
    trending: true,
  },
  {
    id: "5",
    title: "Minimalism in 2024: Less is Still More",
    summary:
      "A look at how minimalist design principles are evolving in the current digital landscape.",
    category: "Design",
    author: {
      name: "Jessica Kim",
      avatar: "https://i.pravatar.cc/150?u=jessica",
      role: "UX Researcher",
    },
    publishedAt: "8h ago",
    readTime: "3 min read",
    likes: 445,
    comments: 19,
  },
];

const CATEGORIES = ["All", "Technology", "Design", "Business", "AI", "Crypto"];

// ============================================================================
// COMPONENTS
// ============================================================================

function NewsCard({ item, index }: { item: NewsItem; index: number }) {
  return (
    <motion.div
      initial={{ opacity: 0, y: 20 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ delay: index * 0.1, duration: 0.4 }}
      whileHover={{ y: -4 }}
      className="group relative overflow-hidden rounded-xl border border-border/40 bg-background/60 p-5 backdrop-blur-sm transition-all hover:border-border/60 hover:shadow-lg hover:bg-background/80"
    >
      {/* Hover Gradient */}
      <div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100 pointer-events-none" />

      <div className="flex flex-col gap-4">
        {/* Header: Author & Meta */}
        <div className="flex items-center justify-between">
          <div className="flex items-center gap-3">
            <Avatar className="h-8 w-8 border border-border/50">
              <AvatarImage src={item.author.avatar} alt={item.author.name} />
              <AvatarFallback>{item.author.name[0]}</AvatarFallback>
            </Avatar>
            <div className="flex flex-col">
              <span className="text-xs font-medium text-foreground">
                {item.author.name}
              </span>
              <span className="text-[10px] text-muted-foreground">
                {item.author.role}
              </span>
            </div>
          </div>
          <div className="flex items-center gap-2">
            {item.trending && (
              <Badge
                variant="secondary"
                className="bg-amber-500/10 text-amber-600 hover:bg-amber-500/20 border-amber-500/20 text-[10px] px-2 py-0 h-5 gap-1"
              >
                <TrendingUp className="h-3 w-3" />
                Trending
              </Badge>
            )}
            <span className="text-xs text-muted-foreground flex items-center gap-1">
              <Clock className="h-3 w-3" />
              {item.publishedAt}
            </span>
          </div>
        </div>

        {/* Content */}
        <div className="space-y-2">
          <div className="flex items-start justify-between gap-4">
            <h3 className="text-lg font-semibold leading-tight tracking-tight text-foreground group-hover:text-primary transition-colors">
              {item.title}
            </h3>
            <Badge
              variant="outline"
              className="shrink-0 text-[10px] font-normal opacity-70"
            >
              {item.category}
            </Badge>
          </div>
          <p className="text-sm text-muted-foreground line-clamp-2 leading-relaxed">
            {item.summary}
          </p>
        </div>

        {/* Footer: Actions & Stats */}
        <div className="flex items-center justify-between pt-2 border-t border-border/30 mt-2">
          <div className="flex items-center gap-4">
            <button className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors">
              <MessageSquare className="h-3.5 w-3.5" />
              {item.comments}
            </button>
            <button className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors">
              <Share2 className="h-3.5 w-3.5" />
              Share
            </button>
          </div>

          <div className="flex items-center gap-2">
            <span className="text-xs text-muted-foreground">
              {item.readTime}
            </span>
            <Button
              size="sm"
              variant="ghost"
              className="h-7 w-7 p-0 rounded-full hover:bg-primary/10 hover:text-primary"
            >
              <Bookmark className="h-3.5 w-3.5" />
            </Button>
            <Button
              size="sm"
              variant="ghost"
              className="h-7 w-7 p-0 rounded-full hover:bg-primary/10 hover:text-primary"
            >
              <ArrowRight className="h-3.5 w-3.5" />
            </Button>
          </div>
        </div>
      </div>
    </motion.div>
  );
}

export function NewsFeed() {
  const [activeCategory, setActiveCategory] = useState("All");
  const [searchQuery, setSearchQuery] = useState("");

  const filteredNews = NEWS_ITEMS.filter((item) => {
    const matchesCategory =
      activeCategory === "All" || item.category === activeCategory;
    const matchesSearch =
      item.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
      item.summary.toLowerCase().includes(searchQuery.toLowerCase());
    return matchesCategory && matchesSearch;
  });

  return (
    <div className="w-full mx-auto space-y-8 min-h-[600px]">
      {/* Header Section */}
      <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
        <div className="space-y-1">
          <h2 className="text-2xl font-bold tracking-tight flex items-center gap-2">
            <Newspaper className="h-6 w-6 text-primary" />
            Latest Updates
          </h2>
          <p className="text-sm text-muted-foreground">
            Stay informed with the most recent news and insights.
          </p>
        </div>

        <div className="flex items-center gap-2">
          <div className="relative">
            <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
            <Input
              placeholder="Search news..."
              className="pl-9 w-[200px] bg-background/50 backdrop-blur-sm border-border/50 focus:bg-background transition-all"
              value={searchQuery}
              onChange={(e) => setSearchQuery(e.target.value)}
            />
          </div>
          <Button variant="outline" size="icon" className="shrink-0">
            <Filter className="h-4 w-4" />
          </Button>
        </div>
      </div>

      {/* Categories */}
      <div className="flex items-center gap-2 overflow-x-auto pb-2 scrollbar-hide">
        {CATEGORIES.map((category) => (
          <button
            key={category}
            onClick={() => setActiveCategory(category)}
            className={`
              px-4 py-1.5 rounded-full text-xs font-medium transition-all whitespace-nowrap
              ${
                activeCategory === category
                  ? "bg-primary text-primary-foreground shadow-md shadow-primary/20"
                  : "bg-secondary/50 text-secondary-foreground hover:bg-secondary hover:text-foreground border border-transparent hover:border-border/50"
              }
            `}
          >
            {category}
          </button>
        ))}
      </div>

      {/* News Grid */}
      <div className="grid gap-4">
        <AnimatePresence mode="popLayout">
          {filteredNews.length > 0 ? (
            filteredNews.map((item, index) => (
              <NewsCard key={item.id} item={item} index={index} />
            ))
          ) : (
            <motion.div
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              className="flex flex-col items-center justify-center py-12 text-center space-y-3"
            >
              <div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center">
                <Search className="h-6 w-6 text-muted-foreground" />
              </div>
              <p className="text-muted-foreground font-medium">
                No news found matching your criteria.
              </p>
              <Button
                variant="link"
                onClick={() => {
                  setActiveCategory("All");
                  setSearchQuery("");
                }}
              >
                Clear filters
              </Button>
            </motion.div>
          )}
        </AnimatePresence>
      </div>

      {/* Load More */}
      {filteredNews.length > 0 && (
        <div className="flex justify-center pt-4">
          <Button
            variant="ghost"
            className="gap-2 text-muted-foreground hover:text-foreground"
          >
            <MoreHorizontal className="h-4 w-4" />
            Load More Stories
          </Button>
        </div>
      )}
    </div>
  );
}

Installation

npx shadcn@latest add @uitripled/news-feed

Usage

import { NewsFeed } from "@/components/news-feed"
<NewsFeed />