A Linear-style issue card with priority indicators, status icons, assignee avatars, labels, and keyboard selection. Perfect for project management and task tracking interfaces.
// Dependencies: react ^18, lucide-react
import React from 'react';
import { MessageSquare, Clock } from 'lucide-react';
type Label = {
id: string;
name: string;
color: string;
};
type Issue = {
id: string;
identifier: string;
title: string;
status: 'backlog' | 'todo' | 'in-progress' | 'done' | 'cancelled';
priority: 'urgent' | 'high' | 'medium' | 'low' | 'none';
assignee?: { name: string; initials: string; color: string };
labels?: Label[];
dueDate?: Date;
commentCount?: number;
estimate?: number;
};
type Props = {
issue: Issue;
onClick?: () => void;
selected?: boolean;
};
function StatusIcon({ status }: { status: Issue['status'] }) {
if (status === 'backlog')
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<circle cx="7" cy="7" r="6" stroke="#52525b" strokeWidth="1.5" strokeDasharray="3 2" />
</svg>
);
if (status === 'todo')
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<circle cx="7" cy="7" r="6" stroke="#71717a" strokeWidth="1.5" />
<circle cx="7" cy="7" r="2" fill="#71717a" />
</svg>
);
if (status === 'in-progress')
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<circle cx="7" cy="7" r="6" stroke="#f59e0b" strokeWidth="1.5" />
<path d="M7 1a6 6 0 0 1 0 12V7L7 1z" fill="#f59e0b" />
</svg>
);
if (status === 'done')
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<circle cx="7" cy="7" r="6" fill="#7c3aed" />
<path d="M4.5 7l2 2 3-3" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<circle cx="7" cy="7" r="6" stroke="#3f3f46" strokeWidth="1.5" />
<path d="M5 5l4 4M9 5l-4 4" stroke="#52525b" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
function PriorityIcon({ priority }: { priority: Issue['priority'] }) {
if (priority === 'urgent')
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<rect x="1" y="2" width="3" height="10" rx="1" fill="#ef4444" />
<rect x="5.5" y="2" width="3" height="10" rx="1" fill="#ef4444" />
<rect x="10" y="2" width="3" height="10" rx="1" fill="#ef4444" />
</svg>
);
if (priority === 'high')
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<rect x="1" y="5" width="3" height="7" rx="1" fill="#f97316" />
<rect x="5.5" y="3" width="3" height="9" rx="1" fill="#f97316" />
<rect x="10" y="1" width="3" height="11" rx="1" fill="#f97316" />
</svg>
);
if (priority === 'medium')
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<rect x="1" y="7" width="3" height="5" rx="1" fill="#eab308" />
<rect x="5.5" y="4" width="3" height="8" rx="1" fill="#eab308" />
<rect x="10" y="1" width="3" height="11" rx="1" fill="#3f3f46" />
</svg>
);
if (priority === 'low')
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<rect x="1" y="9" width="3" height="3" rx="1" fill="#3b82f6" />
<rect x="5.5" y="5" width="3" height="7" rx="1" fill="#3f3f46" />
<rect x="10" y="1" width="3" height="11" rx="1" fill="#3f3f46" />
</svg>
);
return <span className="w-3.5 h-0.5 rounded bg-zinc-700 inline-block" />;
}
function formatDue(date: Date): string {
const now = new Date();
const diff = date.getTime() - now.getTime();
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
if (days === 0) return 'Today';
if (days === 1) return 'Tomorrow';
if (days < 0) return `${Math.abs(days)}d overdue`;
return `${days}d`;
}
export function IssueCard({ issue, onClick, selected }: Props) {
return (
<button
onClick={onClick}
className={`w-full flex items-center gap-2.5 px-4 py-2.5 text-left transition-colors border-b border-white/[0.05] last:border-0 group ${
selected
? 'bg-indigo-500/[0.08] border-l-2 border-l-indigo-500 pl-[14px]'
: 'hover:bg-white/[0.03]'
}`}
>
<span className="shrink-0 flex items-center justify-center w-4">
<PriorityIcon priority={issue.priority} />
</span>
<span className="shrink-0 flex items-center justify-center w-4">
<StatusIcon status={issue.status} />
</span>
<span className="text-[11px] font-mono text-zinc-600 shrink-0 w-14">
{issue.identifier}
</span>
<span className="flex-1 text-sm text-zinc-300 truncate">{issue.title}</span>
{issue.labels && issue.labels.length > 0 && (
<div className="flex items-center gap-1 shrink-0">
{issue.labels.map((label) => (
<span
key={label.id}
className="text-[10px] px-1.5 py-0.5 rounded-full font-medium border"
style={{ color: label.color, borderColor: `${label.color}40`, backgroundColor: `${label.color}15` }}
>
{label.name}
</span>
))}
</div>
)}
{issue.dueDate && (
<span className="flex items-center gap-1 text-[11px] text-zinc-500 shrink-0">
<Clock className="w-3 h-3" />
{formatDue(issue.dueDate)}
</span>
)}
{issue.commentCount !== undefined && issue.commentCount > 0 && (
<span className="flex items-center gap-1 text-[11px] text-zinc-600 shrink-0">
<MessageSquare className="w-3 h-3" />
{issue.commentCount}
</span>
)}
{issue.estimate !== undefined && (
<span className="text-[11px] text-zinc-600 shrink-0">{issue.estimate}pt</span>
)}
{issue.assignee && (
<div
className="w-5 h-5 rounded-full flex items-center justify-center text-[9px] font-bold text-white shrink-0"
style={{ backgroundColor: issue.assignee.color }}
title={issue.assignee.name}
>
{issue.assignee.initials}
</div>
)}
</button>
);
}Unlock to copy
Free access to all patterns
Command Palette
Linear
Global Search with Results Grouping
Linear
Recent + Suggested Search
Raycast
Filter Builder (AND / OR)
Linear
Sidebar Navigation
Linear
Collapsible Nested Tree Nav
Notion
Deployment Status Card
Vercel
Empty State
Vercel
Slash Command Menu
Notion
Stats & Metrics Row
Vercel
Issue / Task Card
Linear
Activity Feed
Linear + Slack
Properties Panel
Notion + Linear
Inline Data Table
Notion + Linear