Creating Custom Renderers
Sightline's render props pattern allows complete customization of how incidents are displayed. This guide walks you through creating custom renderers for each component type.
Custom renderers receive strongly-typed props including incident data and interaction handlers. See the Types Reference for complete type definitions.
Overview
Sightline supports four types of custom renderers:
| Renderer Type | Purpose | Key Props |
|---|---|---|
| Marker | Map pin/indicator | incidents[], isSelected, isNew, onClick, size, color |
| Popup | Info overlay on marker click | incident, onClose, onViewDetails? |
| Feed Item | Sidebar list entry | incident, isSelected, isHighlighted, onClick |
| Cluster | Grouped marker indicator | pointCount, coordinates, onExpand, onClick, size, color |
Quick Start
Pass custom renderers directly to the MapEngine component:
import { MapEngine } from '@/core';
import { IncidentFeed } from '@/controls';
import { CustomMarker } from './renderers/custom-marker';
import { CustomPopup } from './renderers/custom-popup';
import { CustomFeedItem } from './renderers/custom-feed-item';
function MyMap({ incidents }) {
return (
<>
<MapEngine
incidents={incidents}
renderMarker={(props) => <CustomMarker {...props} />}
renderPopup={(props) => <CustomPopup {...props} />}
renderCluster={(props) => <CustomCluster {...props} />}
/>
<IncidentFeed
incidents={incidents}
renderFeedItem={(props) => <CustomFeedItem {...props} />}
/>
</>
);
}Creating a Custom Marker
Define the Marker Component
Create a new component that accepts IncidentDisplay and interaction props:
'use client';
import type { IncidentDisplay } from '@disclosureos/sightline-core';
import { cn } from '@/lib/utils';
interface CustomMarkerProps {
incidents: IncidentDisplay[]; // Array (may have stacked incidents at same location)
isSelected: boolean;
isNew: boolean;
onClick: () => void;
size: number;
color: string;
}
export function CustomMarker({ incidents, isSelected, onClick, size, color }: CustomMarkerProps) {
// Use primary incident for styling
const incident = incidents[0];
const sensitivity = incident.location.locationSensitivity;
return (
<button
onClick={onClick}
className={cn(
'w-6 h-6 rounded-full border-2 transition-all',
'hover:scale-110 focus:outline-none focus:ring-2 focus:ring-offset-2',
isSelected && 'scale-125 ring-2 ring-primary',
sensitivity === 'critical' && 'bg-red-500 border-red-700',
sensitivity === 'high' && 'bg-orange-500 border-orange-700',
sensitivity === 'moderate' && 'bg-yellow-500 border-yellow-700',
!sensitivity && 'bg-blue-500 border-blue-700'
)}
aria-label={`View ${incident.location.name}`}
/>
);
}Add Visual Indicators
Enhance your marker with icons or additional visual cues:
import { AlertTriangle, Shield, MapPin } from 'lucide-react';
export function CustomMarker({ incidents, isSelected, onClick, color }: CustomMarkerProps) {
const incident = incidents[0]; // Use primary incident
const hasEvidence = incident.sensorEvidence?.evidenceTypes?.length > 0;
const hasMilitary = incident.witnesses?.witnessCategories?.some(cat => cat.startsWith('military_'));
return (
<button
onClick={onClick}
className={cn(
'relative flex items-center justify-center',
'w-8 h-8 rounded-full shadow-lg transition-all',
isSelected ? 'scale-125 z-10' : 'hover:scale-110'
)}
style={{ backgroundColor: color || incident.sensitivityColor }}
>
{hasMilitary ? (
<Shield className="w-4 h-4 text-white" />
) : hasEvidence ? (
<AlertTriangle className="w-4 h-4 text-white" />
) : (
<MapPin className="w-4 h-4 text-white" />
)}
{/* Selection ring */}
{isSelected && (
<span className="absolute inset-0 rounded-full ring-2 ring-white ring-offset-2" />
)}
</button>
);
}Add Accessibility
Ensure your marker is accessible:
<button
onClick={onClick}
role="button"
aria-label={`${incident.location.name}, ${incident.temporal.date}${
isSelected ? ' (selected)' : ''
}`}
aria-pressed={isSelected}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
}}
>
{/* ... */}
</button>Creating a Custom Popup
Define the Popup Component
'use client';
import type { IncidentDisplay } from '@disclosureos/sightline-core';
import { X, Calendar, MapPin, Users, ExternalLink } from 'lucide-react';
interface CustomPopupProps {
incident: IncidentDisplay;
onClose: () => void;
}
export function CustomPopup({ incident, onClose }: CustomPopupProps) {
return (
<div className="w-80 bg-card rounded-lg shadow-xl border border-border overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-3 bg-muted/50 border-b">
<h3 className="font-semibold text-sm truncate flex-1">
{incident.location.name}
</h3>
<button
onClick={onClose}
className="p-1 rounded hover:bg-muted"
aria-label="Close popup"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-3">
{/* Date */}
<div className="flex items-center gap-2 text-sm">
<Calendar className="w-4 h-4 text-muted-foreground" />
<span>{incident.formattedDate}</span>
</div>
{/* Location */}
<div className="flex items-center gap-2 text-sm">
<MapPin className="w-4 h-4 text-muted-foreground" />
<span>{incident.location.country}</span>
</div>
{/* Witnesses */}
{incident.witnesses?.witnessCount && (
<div className="flex items-center gap-2 text-sm">
<Users className="w-4 h-4 text-muted-foreground" />
<span>{incident.witnesses.witnessCount} witnesses</span>
</div>
)}
{/* Summary */}
{incident.summary && (
<p className="text-sm text-muted-foreground line-clamp-3">
{incident.summary}
</p>
)}
</div>
{/* Footer */}
<div className="p-3 bg-muted/30 border-t">
<button
className="w-full flex items-center justify-center gap-2 text-sm text-primary hover:underline"
onClick={() => {/* Open detail view */}}
>
View Full Details
<ExternalLink className="w-3 h-3" />
</button>
</div>
</div>
);
}Add Media Preview
If incidents have media, show a thumbnail:
{incident.featuredMedia && (
<div className="relative h-32 -mx-4 mb-4">
<img
src={incident.featuredMedia.url}
alt={incident.featuredMedia.caption || 'Incident media'}
className="w-full h-full object-cover"
/>
{incident.media && incident.media.length > 1 && (
<span className="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-2 py-1 rounded">
+{incident.media.length - 1} more
</span>
)}
</div>
)}Handle Keyboard Navigation
export function CustomPopup({ incident, onClose }: CustomPopupProps) {
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
return (
<div role="dialog" aria-label={`Details for ${incident.location.name}`}>
{/* ... */}
</div>
);
}Creating a Custom Feed Item
Define the Feed Item Component
'use client';
import type { IncidentDisplay } from '@disclosureos/sightline-core';
import { cn } from '@/lib/utils';
import { Calendar, MapPin, Eye } from 'lucide-react';
interface CustomFeedItemProps {
incident: IncidentDisplay;
isSelected: boolean;
onClick: () => void;
}
export function CustomFeedItem({ incident, isSelected, onClick }: CustomFeedItemProps) {
return (
<button
onClick={onClick}
className={cn(
'w-full text-left p-4 rounded-lg border transition-all',
'hover:bg-accent/50 focus:outline-none focus:ring-2 focus:ring-primary',
isSelected
? 'bg-accent border-primary ring-2 ring-primary/20'
: 'border-border'
)}
>
{/* Location Name */}
<h4 className="font-medium text-sm mb-2 line-clamp-1">
{incident.location.name}
</h4>
{/* Metadata Row */}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="w-3 h-3" />
{incident.formattedDate}
</span>
<span className="flex items-center gap-1">
<MapPin className="w-3 h-3" />
{incident.location.country}
</span>
{incident.witnesses?.witnessCount && (
<span className="flex items-center gap-1">
<Eye className="w-3 h-3" />
{incident.witnesses.witnessCount}
</span>
)}
</div>
{/* Sensitivity Badge */}
{incident.sensitivityLabel && (
<div className="mt-2">
<span
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
style={{
backgroundColor: `${incident.sensitivityColor}20`,
color: incident.sensitivityColor,
}}
>
{incident.sensitivityLabel}
</span>
</div>
)}
</button>
);
}Add Compact Variant
Create a variant for denser displays:
export function CompactFeedItem({ incident, isSelected, onClick }: CustomFeedItemProps) {
return (
<button
onClick={onClick}
className={cn(
'w-full flex items-center gap-3 p-2 rounded transition-all',
isSelected ? 'bg-accent' : 'hover:bg-accent/50'
)}
>
{/* Color indicator */}
<div
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: incident.sensitivityColor || '#888' }}
/>
{/* Content */}
<div className="flex-1 min-w-0">
<span className="text-sm truncate block">{incident.location.name}</span>
<span className="text-xs text-muted-foreground">{incident.formattedDate}</span>
</div>
{/* Selection indicator */}
{isSelected && (
<div className="w-1.5 h-1.5 rounded-full bg-primary" />
)}
</button>
);
}Creating a Custom Cluster
Define the Cluster Component
'use client';
import type { ClusterRendererProps } from '@disclosureos/sightline-core';
import { cn } from '@/lib/utils';
export function CustomCluster({
pointCount,
incidents,
onClick,
size: clusterSize,
color
}: ClusterRendererProps) {
// Use provided size or calculate based on count
const sizeClass = clusterSize < 30 ? 'w-8 h-8 text-xs' :
clusterSize < 40 ? 'w-10 h-10 text-sm' : 'w-12 h-12 text-base';
return (
<button
onClick={onClick}
style={{ backgroundColor: color }}
className={cn(
'rounded-full flex items-center justify-center',
'font-bold text-white shadow-lg transition-transform hover:scale-110',
sizeClass
)}
aria-label={`Cluster of ${pointCount} incidents. Click to zoom in.`}
>
{pointCount}
</button>
);
}Add Cluster Breakdown
Show composition on hover:
export function CustomCluster({ count, incidents, onClick }: CustomClusterProps) {
const [showBreakdown, setShowBreakdown] = useState(false);
const typeBreakdown = incidents.reduce((acc, inc) => {
const type = inc.classification?.incidentType || 'unknown';
acc[type] = (acc[type] || 0) + 1;
return acc;
}, {} as Record<string, number>);
return (
<div className="relative">
<button
onClick={onClick}
onMouseEnter={() => setShowBreakdown(true)}
onMouseLeave={() => setShowBreakdown(false)}
className="..."
>
{count}
</button>
{showBreakdown && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 p-2 bg-card rounded-lg shadow-lg border text-xs whitespace-nowrap">
<div className="font-medium mb-1">{count} Incidents</div>
{Object.entries(typeBreakdown).map(([type, cnt]) => (
<div key={type} className="text-muted-foreground">
{type}: {cnt}
</div>
))}
</div>
)}
</div>
);
}Best Practices
Performance
- Memoize components - Use
memo()to prevent unnecessary re-renders - Avoid inline styles - Use CSS classes or CSS-in-JS solutions
- Lazy load media - Use
loading="lazy"for images
import { memo } from 'react';
import type { MarkerRendererProps } from '@disclosureos/sightline-core';
export const CustomMarker = memo(function CustomMarker({
incidents,
isSelected,
isNew,
onClick,
size,
color
}: MarkerRendererProps) {
// ... component code
});Accessibility
- Always include
aria-labelfor buttons - Support keyboard navigation
- Ensure sufficient color contrast
- Use semantic HTML elements
TypeScript
- Import types from
@disclosureos/sightline-core - Define explicit prop interfaces
- Use strict null checks
import type { IncidentDisplay } from '@disclosureos/sightline-core';
interface CustomRendererProps {
incident: IncidentDisplay;
isSelected: boolean;
onClick: () => void;
}Related
- Configuration Reference - Configure which renderers to use
- Examples - More renderer examples
- Types Reference - Complete type definitions
- Customization Guide - Theme and style customization