An inline-editable properties panel inspired by Notion and Linear. Supports text, select, multiselect, date, person, status and more property types — all editable inline.
Issue
Document
// Dependencies: react ^18, lucide-react
import React, { useState, useRef, useEffect } from 'react';
import {
Type, Calendar, ChevronDown, User, Hash, Link2,
CheckSquare, Square, Circle, Tag, Check,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
type SelectOption = { id: string; label: string; color?: string };
type Property = {
id: string;
label: string;
type: 'text' | 'date' | 'select' | 'multiselect' | 'person' | 'number' | 'url' | 'checkbox' | 'status';
value: any;
options?: SelectOption[];
placeholder?: string;
};
type Props = {
title?: string;
properties: Property[];
onUpdate?: (id: string, value: any) => void;
};
const TYPE_ICONS: Record<Property['type'], LucideIcon> = {
text: Type, date: Calendar, select: ChevronDown, multiselect: Tag,
person: User, number: Hash, url: Link2, checkbox: CheckSquare, status: Circle,
};
const STATUS_COLORS: Record<string, string> = {
'Todo': 'bg-zinc-600', 'In Progress': 'bg-yellow-500', 'Done': 'bg-emerald-500',
'Cancelled': 'bg-zinc-500', 'Published': 'bg-emerald-500', 'Draft': 'bg-zinc-500',
'Review': 'bg-blue-500', 'High': 'bg-orange-500', 'Medium': 'bg-yellow-500', 'Low': 'bg-zinc-500',
};
const BADGE_COLOR_MAP: Record<string, string> = {
blue: 'bg-blue-500/15 text-blue-300 border-blue-500/20',
green: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/20',
yellow: 'bg-yellow-500/15 text-yellow-300 border-yellow-500/20',
orange: 'bg-orange-500/15 text-orange-300 border-orange-500/20',
red: 'bg-red-500/15 text-red-300 border-red-500/20',
purple: 'bg-purple-500/15 text-purple-300 border-purple-500/20',
default: 'bg-zinc-800 text-zinc-400 border-zinc-700/50',
};
function BadgePill({ label, color }: { label: string; color?: string }) {
const cls = BADGE_COLOR_MAP[color ?? 'default'] ?? BADGE_COLOR_MAP.default;
return <span className={"text-[11px] px-1.5 py-0.5 rounded border font-medium " + cls}>{label}</span>;
}
function PropertyRow({ property, onUpdate }: { property: Property; onUpdate?: (id: string, value: any) => void }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<any>(property.value);
const [dropdownOpen, setDropdownOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const Icon = TYPE_ICONS[property.type];
useEffect(() => { if (editing && inputRef.current) inputRef.current.focus(); }, [editing]);
const commit = (val: any) => { setEditing(false); setDropdownOpen(false); onUpdate?.(property.id, val); };
const renderValue = () => {
const v = draft;
if (property.type === 'status') {
return v ? (
<span className="flex items-center gap-1.5">
<span className={"w-2 h-2 rounded-full " + (STATUS_COLORS[v] ?? 'bg-zinc-600')} />
<span className="text-[13px] text-zinc-300">{v}</span>
</span>
) : <span className="text-[13px] text-zinc-600">{property.placeholder ?? '—'}</span>;
}
if (property.type === 'select') {
if (!v) return <span className="text-[13px] text-zinc-600">{property.placeholder ?? '—'}</span>;
const opt = property.options?.find((o) => o.id === v);
return <BadgePill label={opt?.label ?? v} color={opt?.color} />;
}
if (property.type === 'multiselect') {
const vals = v as string[] | null;
if (!vals?.length) return <span className="text-[13px] text-zinc-600">{property.placeholder ?? '—'}</span>;
return <div className="flex flex-wrap gap-1">{vals.map((id) => { const opt = property.options?.find((o) => o.id === id); return <BadgePill key={id} label={opt?.label ?? id} color={opt?.color} />; })}</div>;
}
if (property.type === 'person') {
return v ? (
<span className="flex items-center gap-1.5">
<span className="w-5 h-5 rounded-full flex items-center justify-center text-[9px] font-bold text-white shrink-0" style={{ backgroundColor: v.color }}>{v.initials}</span>
<span className="text-[13px] text-zinc-300">{v.name}</span>
</span>
) : <span className="text-[13px] text-zinc-600">Unassigned</span>;
}
if (property.type === 'checkbox') {
return (
<button onClick={() => commit(!v)} className="flex items-center">
{v ? <CheckSquare className="w-4 h-4 text-indigo-400" /> : <Square className="w-4 h-4 text-zinc-600" />}
</button>
);
}
if (property.type === 'url') {
return v ? <span className="text-[13px] text-indigo-400 truncate max-w-[160px]">{v.replace(/^https?:\/\//, '')}</span>
: <span className="text-[13px] text-zinc-600">{property.placeholder ?? '—'}</span>;
}
if (property.type === 'number') {
return v != null ? <span className="text-[13px] text-zinc-300 font-mono">{v}</span>
: <span className="text-[13px] text-zinc-600">{property.placeholder ?? '—'}</span>;
}
return v ? <span className="text-[13px] text-zinc-300">{v}</span>
: <span className="text-[13px] text-zinc-600">{property.placeholder ?? '—'}</span>;
};
const isSimpleEdit = ['text', 'number', 'url', 'date'].includes(property.type);
const isDropdown = ['select', 'multiselect', 'status'].includes(property.type);
return (
<div className="group relative">
<div
className="grid items-start py-1.5 px-3 hover:bg-white/[0.02] rounded-lg cursor-pointer transition-colors"
style={{ gridTemplateColumns: '40% 60%' }}
onClick={() => { if (property.type !== 'checkbox') { if (isSimpleEdit) setEditing(true); else if (isDropdown) setDropdownOpen((o) => !o); } }}
>
<div className="flex items-center gap-2 pt-0.5 min-w-0">
<Icon className="w-3 h-3 text-zinc-600 shrink-0" />
<span className="text-[12px] text-zinc-600 truncate">{property.label}</span>
</div>
<div className="min-w-0 pl-2">
{editing ? (
<input
ref={inputRef}
value={draft ?? ''}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => commit(draft)}
onKeyDown={(e) => { if (e.key === 'Enter') commit(draft); if (e.key === 'Escape') { setEditing(false); setDraft(property.value); } }}
className="w-full bg-zinc-800 border border-indigo-500/40 rounded px-1.5 py-0.5 text-[13px] text-white outline-none"
onClick={(e) => e.stopPropagation()}
/>
) : renderValue()}
</div>
</div>
{dropdownOpen && property.options && (
<div className="absolute left-0 right-0 z-50 mt-0.5 mx-3 rounded-lg border border-[#2a2a2a] bg-[#1a1a1a] shadow-2xl overflow-hidden">
{property.options.map((opt) => {
const isSelected = property.type === 'multiselect'
? ((draft as string[] | null) ?? []).includes(opt.id) : draft === opt.id;
return (
<button key={opt.id} className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-white/[0.06] transition-colors"
onClick={(e) => { e.stopPropagation(); if (property.type === 'multiselect') { const cur = (draft as string[] | null) ?? []; const next = isSelected ? cur.filter((id) => id !== opt.id) : [...cur, opt.id]; setDraft(next); onUpdate?.(property.id, next); } else { commit(opt.id); } }}>
{isSelected ? <Check className="w-3 h-3 text-indigo-400 shrink-0" /> : <span className="w-3 h-3 shrink-0" />}
<BadgePill label={opt.label} color={opt.color} />
</button>
);
})}
<button className="w-full px-3 py-2 text-left text-[11px] text-zinc-600 hover:bg-white/[0.04] border-t border-white/[0.06]" onClick={(e) => { e.stopPropagation(); setDropdownOpen(false); }}>Close</button>
</div>
)}
</div>
);
}
export function PropertiesPanel({ title, properties, onUpdate }: Props) {
return (
<div className="rounded-xl border border-white/[0.08] bg-[#111]">
{title && <div className="px-4 py-3 border-b border-white/[0.06]"><h3 className="text-sm font-semibold text-white">{title}</h3></div>}
<div className="py-2">
{properties.map((prop) => <PropertyRow key={prop.id} property={prop} onUpdate={onUpdate} />)}
</div>
</div>
);
}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
Tab Bar with Overflow Menu
Vercel
Breadcrumb with Dropdowns
Linear
Workspace Switcher
Slack
Deployment Status Card
Vercel
Empty State
Vercel
Slash Command Menu
Notion
Stats & Metrics Row
Vercel
Single Big Metric Card
Stripe
Confirmation Dialog
Linear
Slide-Over Panel
Stripe
Toast / Snackbar Stack
Vercel
Issue / Task Card
Linear
Activity Feed
Linear + Slack
Properties Panel
Notion + Linear
Multi-Step Form Wizard
Stripe
Inline Data Table
Notion + Linear