# ABUI > A shadcn/ui-compatible registry of reusable React components, blocks, and Tailwind CSS utilities by Antonio Brandao. Install any item with npx shadcn add @abui/. ABUI is a public, open-source component registry that follows the shadcn/ui registry specification and Vercel's components.build taxonomy. Every item is a copy-and-own file: installing it writes TypeScript/React source into your project rather than adding a package dependency. Items are styled with Tailwind CSS v4 and shadcn/ui design tokens (background, foreground, primary, muted, border, etc.), so they adopt the host project's theme. ## Installation ABUI is listed in the shadcn registry directory, so the `@abui` namespace resolves without configuration: ```bash npx shadcn@latest add @abui/ ``` To pin the registry manually, add this to `components.json`: ```json { "registries": { "@abui": "https://www.abui.io/r/{name}.json" } } ``` Requirements: a React project initialised with shadcn/ui (`npx shadcn@latest init`), Tailwind CSS v4, and the `@/lib/utils` `cn` helper. ## Blocks ### Timeline A Gantt-chart style timeline with draggable time slots. - Docs: https://www.abui.io/blocks/timeline - Registry item: https://www.abui.io/r/timeline.json - Type: registry:block - Install: `npx shadcn@latest add @abui/timeline` - npm dependencies: @radix-ui/react-slot, @dnd-kit/core, tunnel-rat Source: https://github.com/antoniobrandao/abui/blob/master/registry/abui/ui/timeline.tsx ```tsx "use client" import * as React from "react" import { useState, useRef, useEffect, createContext, useContext, useMemo } from "react" import { Slot } from "@radix-ui/react-slot" import { DndContext, DragOverlay, useSensor, useSensors, PointerSensor, closestCenter, useDraggable, useDroppable, } from "@dnd-kit/core" import tunnel from "tunnel-rat" import { cn } from "@/lib/utils" /* ============================================================================ * TYPES & INTERFACES * ========================================================================== */ export interface TimelineSlotData { id: string rowId: string startTime: string // "14:30" duration: number // minutes [key: string]: any // Additional custom data } export interface TimelineRowData { id: string label: string [key: string]: any // Additional custom data } export interface TimelineConfig { startHour: number endHour: number snapIntervalMinutes?: number columnWidth?: number } /* ============================================================================ * CONTEXT * ========================================================================== */ type TimelineContextValue = { // Configuration config: TimelineConfig pixelsPerMinute: number timelineWidth: number // Refs timelineRef: React.RefObject columnRef: React.RefObject // Tunnel for drag preview dragPreviewTunnel: ReturnType // Callbacks onSlotPositionChange?: (slotId: string, newTime: string, newRowId: string) => Promise onValidateDrop?: (slotId: string, newTime: string, newRowId: string) => boolean onSlotClick?: (slotId: string) => void } const TimelineContext = createContext(null) function useTimeline() { const context = useContext(TimelineContext) if (!context) { throw new Error("Timeline components must be used within TimelineProvider") } return context } /* ============================================================================ * UTILITIES * ========================================================================== */ export const timeToMinutes = (time: string): number => { const [hours, minutes] = time.split(":").map(Number) return hours * 60 + minutes } export const minutesToTime = (minutes: number): string => { const hours = Math.floor(minutes / 60) const mins = minutes % 60 return `${hours.toString().padStart(2, "0")}:${mins.toString().padStart(2, "0")}` } /* ============================================================================ * TIMELINE PROVIDER * ========================================================================== */ interface TimelineProviderProps { children: React.ReactNode config: TimelineConfig percentageInView?: number onSlotPositionChange?: (slotId: string, newTime: string, newRowId: string) => Promise onValidateDrop?: (slotId: string, newTime: string, newRowId: string) => boolean onSlotClick?: (slotId: string) => void style?: React.CSSProperties className?: string } export function TimelineProvider({ children, config, percentageInView = 100, onSlotPositionChange, onValidateDrop, onSlotClick, style, className, }: TimelineProviderProps) { const [viewportWidth, setViewportWidth] = useState(0) const timelineRef = useRef(null) const columnRef = useRef(null) // Create tunnel for drag preview (stable across renders) const dragPreviewTunnel = useMemo(() => tunnel(), []) const columnWidth = config.columnWidth || 112 // Measure viewport useEffect(() => { const measure = () => { if (timelineRef.current) { setViewportWidth(timelineRef.current.clientWidth - columnWidth) } } measure() window.addEventListener("resize", measure) const timeout = setTimeout(measure, 100) return () => { window.removeEventListener("resize", measure) clearTimeout(timeout) } }, [columnWidth]) // Calculate timeline dimensions const totalMinutes = (config.endHour - config.startHour) * 60 const basePixelsPerMinute = viewportWidth > 0 ? viewportWidth / totalMinutes : 10 const pixelsPerMinute = basePixelsPerMinute * (100 / percentageInView) const timelineWidth = totalMinutes * pixelsPerMinute const contextValue: TimelineContextValue = { config, pixelsPerMinute, timelineWidth, timelineRef, columnRef, dragPreviewTunnel, onSlotPositionChange, onValidateDrop, onSlotClick, } return (
{children}
) } /* ============================================================================ * TIMELINE (Main Container with DnD) * ========================================================================== */ interface TimelineProps { slots: TimelineSlotData[] rows: TimelineRowData[] children: React.ReactNode className?: string } export function Timeline({ slots, rows, children, className }: TimelineProps) { const { config, pixelsPerMinute, timelineRef, dragPreviewTunnel, onSlotPositionChange, onValidateDrop } = useTimeline() const [mousePosition, setMousePosition] = useState<{ x: number; y: number } | null>(null) const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 }, }), ) const snapInterval = config.snapIntervalMinutes || 15 const columnWidth = config.columnWidth || 112 // Snapping utilities const snapToInterval = (minutes: number): number => { return Math.round(minutes / snapInterval) * snapInterval } const getSnappedDelta = (deltaX: number): number => { const deltaMinutes = deltaX / pixelsPerMinute const snappedDeltaMinutes = Math.round(deltaMinutes / snapInterval) * snapInterval return snappedDeltaMinutes * pixelsPerMinute } const calculateNewTime = (originalTime: string, deltaX: number): string => { const originalMinutes = timeToMinutes(originalTime) const deltaMinutes = Math.round(deltaX / pixelsPerMinute) const newMinutes = originalMinutes + deltaMinutes const snappedMinutes = snapToInterval(newMinutes) const clampedMinutes = Math.max(config.startHour * 60, Math.min((config.endHour - 1) * 60, snappedMinutes)) return minutesToTime(clampedMinutes) } // Mouse handlers const handleMouseMove = (e: React.MouseEvent) => { if (timelineRef.current) { const rect = timelineRef.current.getBoundingClientRect() setMousePosition({ x: e.clientX - rect.left + timelineRef.current.scrollLeft, y: e.clientY - rect.top, }) } } const handleMouseLeave = () => { setMousePosition(null) } // Calculate mouse time const mouseTime = mousePosition ? minutesToTime(Math.floor((mousePosition.x - columnWidth) / pixelsPerMinute) + config.startHour * 60) : null // Drag handlers const [localActiveSlot, setLocalActiveSlot] = useState(null) const [localOverRow, setLocalOverRow] = useState(null) const [localDraggedTime, setLocalDraggedTime] = useState(null) const [isValid, setIsValid] = useState(true) const handleDragStart = (event: any) => { setLocalActiveSlot(event.active.id) } const handleDragOver = (event: any) => { if (event.over) { setLocalOverRow(event.over.id) } else { setLocalOverRow(null) setLocalDraggedTime(null) } } const handleDragMove = (event: any) => { const { over, active, delta } = event if (over) { const slot = slots.find((s: TimelineSlotData) => s.id === active.id) if (slot) { const newTime = calculateNewTime(slot.startTime, delta.x) setLocalDraggedTime(newTime) // Validate with host const valid = onValidateDrop ? onValidateDrop(active.id, newTime, over.id) : true setIsValid(valid) } } } const handleDragCancel = () => { // Clean up all drag state when drag is cancelled (e.g., ESC key) setLocalActiveSlot(null) setLocalOverRow(null) setLocalDraggedTime(null) setIsValid(true) } const handleDragEnd = async (event: any) => { const { active, over, delta } = event setLocalActiveSlot(null) setLocalOverRow(null) setLocalDraggedTime(null) setIsValid(true) if (over) { const slot = slots.find((s: TimelineSlotData) => s.id === active.id) if (!slot) return const newTime = calculateNewTime(slot.startTime, delta.x) const newRowId = over.id // Validate if (onValidateDrop && !onValidateDrop(active.id, newTime, newRowId)) { return } // Check if anything changed if (slot.rowId === newRowId && slot.startTime === newTime) { return } // Call position change handler if (onSlotPositionChange) { await onSlotPositionChange(active.id, newTime, newRowId) } } } return (
{/* Mouse time indicator */} {mousePosition && mouseTime && !localActiveSlot && ( )}
{React.Children.map(children, child => { if (React.isValidElement(child)) { return React.cloneElement(child as React.ReactElement, { slots, rows, activeSlotId: localActiveSlot, overRowId: localOverRow, draggedNewTime: localDraggedTime, isValidDrop: isValid, getSnappedDelta, _showDropRegion: !!(localActiveSlot && localDraggedTime), _dropRegionTime: localDraggedTime, }) } return child })}
{/* Drag overlay - receives content from tunnel */} {localActiveSlot && (() => { const activeSlot = slots.find((s: TimelineSlotData) => s.id === localActiveSlot) if (!activeSlot) return null console.log("📺 DragOverlay rendering for slot:", activeSlot.id) return (
{/* Receive tunneled content from the active slot */}
) })()}
) } /* ============================================================================ * TIMELINE HEADER * ========================================================================== */ interface TimelineHeaderProps { className?: string columnLabel?: React.ReactNode } export function TimelineHeader({ className, columnLabel = "Row" }: TimelineHeaderProps) { const { config, pixelsPerMinute, columnRef } = useTimeline() // Generate hour markers const hourMarkers = [] for (let hour = config.startHour; hour < config.endHour; hour++) { hourMarkers.push({ hour, label: `${hour}:00`, position: (hour - config.startHour) * 60 * pixelsPerMinute, }) } return (
{columnLabel}
{hourMarkers.map(marker => (
{marker.label}
))}
) } /* ============================================================================ * TIMELINE ROW * ========================================================================== */ interface TimelineRowProps { row: TimelineRowData slots: TimelineSlotData[] children: (slot: TimelineSlotData) => React.ReactNode renderRowHeader?: (row: TimelineRowData) => React.ReactNode renderDropGhost?: (slot: TimelineSlotData, newTime: string, isValid: boolean) => React.ReactNode className?: string asChild?: boolean } export function TimelineRow({ row, slots, children, renderRowHeader, className, asChild, ...props }: TimelineRowProps & any) { const { config, pixelsPerMinute, timelineWidth } = useTimeline() const Comp = asChild ? Slot : "div" const { setNodeRef, isOver } = useDroppable({ id: row.id, }) const rowSlots = slots.filter((s: TimelineSlotData) => s.rowId === row.id) // Check if this row has invalid drop (from props passed by Timeline) const isValidDrop = props.isValidDrop !== false const isHovered = isOver || props.overRowId === row.id // Try BOTH // Generate grid markers const hourMarkers = [] for (let hour = config.startHour; hour <= config.endHour; hour++) { hourMarkers.push({ hour, position: (hour - config.startHour) * 60 * pixelsPerMinute, }) } const quarterHourMarkers = [] const totalMinutes = (config.endHour - config.startHour) * 60 for (let minutes = 15; minutes < totalMinutes; minutes += 15) { if (minutes % 60 !== 0) { quarterHourMarkers.push({ position: minutes * pixelsPerMinute, }) } } return ( {/* Row label column */}
{renderRowHeader ? renderRowHeader(row) : row.label}
{/* Timeline grid */}
{/* Grid lines */} {quarterHourMarkers.map((marker, idx) => (
))} {hourMarkers.map(marker => (
))} {/* Slots */} {rowSlots.map((slot: TimelineSlotData) => { const slotElement = children(slot) // Clone the element to inject activeSlotId prop return ( {React.isValidElement(slotElement) ? React.cloneElement(slotElement, { activeSlotId: props.activeSlotId } as any) : slotElement} ) })} {/* Drop ghost - shows where slot will land if dropped in this row */} {isHovered && props.draggedNewTime && props.activeSlotId && ( )}
) } /* ============================================================================ * TIMELINE SLOT (Draggable) * ========================================================================== */ interface TimelineSlotProps { slot: TimelineSlotData children: React.ReactNode className?: string asChild?: boolean } export function TimelineSlot({ slot, children, className, asChild, ...props }: TimelineSlotProps & any) { const timeline = useTimeline() const { config, pixelsPerMinute, onSlotClick, dragPreviewTunnel } = timeline const Comp = asChild ? Slot : "div" const { attributes, listeners, setNodeRef, isDragging, transform } = useDraggable({ id: slot.id, }) const startMinutes = timeToMinutes(slot.startTime) const left = (startMinutes - config.startHour * 60) * pixelsPerMinute const width = slot.duration * pixelsPerMinute // Get activeSlotId from Timeline component via props (passed through TimelineRow) const activeSlotIdFromTimeline = props.activeSlotId const isActiveSlot = activeSlotIdFromTimeline === slot.id // When dragging, show slot at its snapped target position (not at cursor) // The DragOverlay will show what you're dragging at cursor position const style = transform && props.getSnappedDelta ? { left: `${left}px`, width: `${Math.max(width, 60)}px`, top: 0, bottom: 0, transform: `translate3d(${props.getSnappedDelta(transform.x)}px, ${transform.y}px, 0)`, } : { left: `${left}px`, width: `${Math.max(width, 60)}px`, top: 0, bottom: 0, } const slotContent = ( { if (onSlotClick && !isDragging) { e.stopPropagation() onSlotClick(slot.id) } }} style={ { "--slot-start-time": slot.startTime, "--slot-duration": `${slot.duration}min`, } as React.CSSProperties } > {children} ) return (
{/* Always render slot content normally */} {slotContent} {/* When active and dragging, ALSO send content through tunnel for DragOverlay */} {isActiveSlot && isDragging && ( <> {console.log("🚇 Sending content through tunnel for slot:", slot.id)} {children} )}
) } /* ============================================================================ * TIMELINE SLOT PRIMITIVES (for content composition) * ========================================================================== */ interface TimelineSlotLabelProps extends React.ComponentProps<"div"> { asChild?: boolean } export function TimelineSlotLabel({ asChild, className, ...props }: TimelineSlotLabelProps) { const Comp = asChild ? Slot : "div" return } interface TimelineSlotContentProps extends React.ComponentProps<"div"> { asChild?: boolean } export function TimelineSlotContent({ asChild, className, ...props }: TimelineSlotContentProps) { const Comp = asChild ? Slot : "div" return } /* ============================================================================ * TIMELINE INDICATORS * ========================================================================== */ function TimelineMouseIndicator({ mouseX, time }: { mouseX: number; time: string }) { return (
{time}
) } interface TimelineDropRegionProps { startTime: string duration: number } export function TimelineDropRegion({ startTime, duration }: TimelineDropRegionProps) { const { config, pixelsPerMinute } = useTimeline() const columnWidth = config.columnWidth || 112 const startMinutes = timeToMinutes(startTime) const endMinutes = startMinutes + duration const endTime = minutesToTime(endMinutes) const startPosition = (startMinutes - config.startHour * 60) * pixelsPerMinute + columnWidth const endPosition = (endMinutes - config.startHour * 60) * pixelsPerMinute + columnWidth const width = endPosition - startPosition return (
{startTime} - {endTime}
) } interface TimelineCurrentTimeProps { className?: string nowLabel?: string } export function TimelineCurrentTime({ className, nowLabel = "Now" }: TimelineCurrentTimeProps) { const { config, pixelsPerMinute } = useTimeline() const columnWidth = config.columnWidth || 112 const [now, setNow] = useState(new Date()) useEffect(() => { const interval = setInterval(() => setNow(new Date()), 60000) // Update every minute return () => clearInterval(interval) }, []) const currentMinutes = now.getHours() * 60 + now.getMinutes() const position = (currentMinutes - config.startHour * 60) * pixelsPerMinute + columnWidth // Only show if within timeline range if (currentMinutes < config.startHour * 60 || currentMinutes > config.endHour * 60) { return null } return (
{nowLabel}: {minutesToTime(currentMinutes)}
) } /* ============================================================================ * TIMELINE GRID (for custom layouts) * ========================================================================== */ interface TimelineGridProps { children: React.ReactNode className?: string } export function TimelineGrid({ children, className, ...props }: TimelineGridProps & any) { const { timelineWidth } = useTimeline() // Get active slot data for drop region and ghost preview const activeSlot = props._showDropRegion && props.slots ? props.slots.find((s: TimelineSlotData) => s.id === props.activeSlotId) : null return (
{React.Children.map(children, child => { if (React.isValidElement(child)) { return React.cloneElement(child as React.ReactElement, props) } return child })} {/* Drop region highlight */} {props._showDropRegion && props._dropRegionTime && activeSlot && ( )}
) } /* ============================================================================ * DROP GHOST (Shows where slot will land) * ========================================================================== */ function TimelineDropGhost({ activeSlotId, allSlots, newTime, isValid, config, pixelsPerMinute, }: { activeSlotId: string allSlots: TimelineSlotData[] newTime: string isValid: boolean config: TimelineConfig pixelsPerMinute: number }) { const slot = allSlots.find(s => s.id === activeSlotId) if (!slot) return null const startMinutes = timeToMinutes(newTime) const left = (startMinutes - config.startHour * 60) * pixelsPerMinute const width = slot.duration * pixelsPerMinute if (!isValid) return null return (
) } /* ============================================================================ * EXPORTS * ========================================================================== */ export { useTimeline, TimelineMouseIndicator } ``` ### Availability A weekly availability designer with draggable and resizable time slots. - Docs: https://www.abui.io/blocks/availability - Registry item: https://www.abui.io/r/availability.json - Type: registry:block - Install: `npx shadcn@latest add @abui/availability` - npm dependencies: lucide-react - Registry dependencies: button Source: https://github.com/antoniobrandao/abui/blob/master/registry/abui/ui/availability.tsx ```tsx "use client" import * as React from "react" import { Clock, Settings, X } from "lucide-react" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { DndContext, DragOverlay, useSensor, useSensors, PointerSensor, useDraggable, useDroppable, DragStartEvent, DragEndEvent, DragMoveEvent, DragOverEvent, } from "@dnd-kit/core" import tunnel from "tunnel-rat" import { nanoid } from "nanoid" // --- Types --- export interface TimeSpan { active?: boolean id: string week_day: number // 0-6 - Sunday-Saturday start_time: string // "HH:mm" end_time: string // "HH:mm" } interface AvailabilityProps { value?: TimeSpan[] onValueChange?: (value: TimeSpan[]) => void disabled?: TimeSpan[] // Disabled regions where no events can exist days?: number[] // 0-6 showAllDays?: boolean // If true, renders all 7 days, but disables interactions on days not in 'days' array timeIncrements?: number // minutes, default 30 startTime?: number // hour 0-23, default 7 endTime?: number // hour 0-23, default 23 useAmPm?: boolean mergeAdjacent?: boolean // default true - merge spans that touch end-to-end slotClassName?: string // className for time slot items (default: "bg-muted") className?: string } // --- Utils --- const timeToMinutes = (time: string) => { const [h, m] = time.split(":").map(Number) return h * 60 + m } const minutesToTime = (minutes: number) => { const h = Math.floor(minutes / 60) const m = minutes % 60 return `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}` } const formatDisplayTime = (time: string, useAmPm: boolean) => { if (!useAmPm) return time const [h, m] = time.split(":").map(Number) const ampm = h >= 12 ? "PM" : "AM" const h12 = h % 12 || 12 return `${h12}:${m.toString().padStart(2, "0")} ${ampm}` } // Helper to generate a simple unique ID (not crypto secure but sufficient for UI) const generateId = () => nanoid() const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] /** * Merges adjacent (contiguous) time spans on the same day. * Adjacent means one span's end_time equals another's start_time. * The merged span keeps the id of the earliest span. */ const mergeAdjacentSpans = (spans: TimeSpan[]): TimeSpan[] => { if (spans.length === 0) return spans // Group by day const byDay = new Map() spans.forEach(span => { const daySpans = byDay.get(span.week_day) || [] daySpans.push(span) byDay.set(span.week_day, daySpans) }) const merged: TimeSpan[] = [] // Process each day byDay.forEach(daySpans => { // Sort by start time const sorted = [...daySpans].sort((a, b) => timeToMinutes(a.start_time) - timeToMinutes(b.start_time)) let current = sorted[0] for (let i = 1; i < sorted.length; i++) { const next = sorted[i] // Check if current and next are adjacent (touching) if (current.end_time === next.start_time) { // Merge: extend current to include next current = { ...current, end_time: next.end_time, // Keep the earliest span's id (current is already earlier due to sorting) } } else { // Not adjacent, push current and move to next merged.push(current) current = next } } // Push the last span merged.push(current) }) return merged } // --- Hooks --- /** * Hook to handle creation dragging logic on a day column. */ function useCalendarCreation({ containerRef, timeIncrements, startTime, endTime, events, disabledEvents = [], onCreate, colIndex, isDayDisabled = false, }: { containerRef: React.RefObject timeIncrements: number startTime: number endTime: number events: TimeSpan[] disabledEvents?: TimeSpan[] onCreate: (dayIndex: number, start: number, end: number) => void colIndex: number isDayDisabled?: boolean }) { const [isCreating, setIsCreating] = React.useState(false) const [creationStart, setCreationStart] = React.useState(null) const [currentMouseY, setCurrentMouseY] = React.useState(null) const totalMinutes = (endTime - startTime) * 60 const startOffset = startTime * 60 // Combine events and disabled regions for constraints // Sort all items by start time to determine safe zones const sortedConstraints = React.useMemo(() => { return [...events, ...disabledEvents].sort((a, b) => timeToMinutes(a.start_time) - timeToMinutes(b.start_time)) }, [events, disabledEvents]) const getMinutesFromY = (y: number) => { if (!containerRef.current) return 0 const rect = containerRef.current.getBoundingClientRect() const relativeY = y - rect.top const percentage = Math.max(0, Math.min(1, relativeY / rect.height)) const minutes = percentage * totalMinutes + startOffset return Math.round(minutes / timeIncrements) * timeIncrements } const handlePointerDown = (e: React.PointerEvent) => { if (isDayDisabled) return // No interaction if day is fully disabled if (e.target !== e.currentTarget) return // Only trigger on empty space // Prevent default drag behavior and text selection, but allow touch scrolling if not handled e.preventDefault() // Capture pointer to track movement even if it leaves the element containerRef.current?.setPointerCapture(e.pointerId) const startMins = getMinutesFromY(e.clientY) // Check strict overlap at start point (cannot start creation inside an event or disabled region) const isOverlapping = sortedConstraints.some(ev => { const s = timeToMinutes(ev.start_time) const e = timeToMinutes(ev.end_time) return startMins >= s && startMins < e }) if (isOverlapping) return // Find constraints const prevEvent = sortedConstraints.filter(ev => timeToMinutes(ev.end_time) <= startMins).pop() const nextEvent = sortedConstraints.find(ev => timeToMinutes(ev.start_time) >= startMins) const minStartMins = prevEvent ? timeToMinutes(prevEvent.end_time) : startOffset const maxEndMins = nextEvent ? timeToMinutes(nextEvent.start_time) : endTime * 60 setCreationStart(startMins) setCurrentMouseY(startMins) setIsCreating(true) const handlePointerMove = (ev: PointerEvent) => { const currentMins = getMinutesFromY(ev.clientY) // Clamp to constraints const clampedMins = Math.max(minStartMins, Math.min(currentMins, maxEndMins)) setCurrentMouseY(clampedMins) } const handlePointerUp = (ev: PointerEvent) => { const currentMins = getMinutesFromY(ev.clientY) let finalStart = Math.min(startMins, currentMins) let finalEnd = Math.max(startMins, currentMins) // Clamp to constraints finalStart = Math.max(minStartMins, finalStart) finalEnd = Math.min(maxEndMins, finalEnd) // Ensure minimum size if (finalEnd - finalStart < timeIncrements) { finalEnd = Math.min(finalStart + timeIncrements, maxEndMins) } // Click-to-create logic (if essentially no drag occurred, try to make a 1-hour slot) if (finalEnd - finalStart <= timeIncrements) { const oneHourEnd = finalStart + 60 finalEnd = Math.min(oneHourEnd, maxEndMins) } if (finalEnd > finalStart) { onCreate(colIndex, finalStart, finalEnd) } setIsCreating(false) setCreationStart(null) setCurrentMouseY(null) // Release pointer capture containerRef.current?.releasePointerCapture(ev.pointerId) // Cleanup listeners (though using setPointerCapture implicitly handles some of this, explicit cleanup is safe) window.removeEventListener("pointermove", handlePointerMove) window.removeEventListener("pointerup", handlePointerUp) } window.addEventListener("pointermove", handlePointerMove) window.addEventListener("pointerup", handlePointerUp) } return { isCreating, creationStart, currentMouseY, totalMinutes, startOffset, sortedConstraints, handlePointerDown, } } // --- Components --- // Context to share dragging state across components const AvailabilityDragContext = React.createContext<{ dragPreviewTunnel: ReturnType activeId: string | null activeSpan: TimeSpan | null overDayIndex: number | null deltaY: number timeIncrements: number isDropValid: boolean } | null>(null) export function Availability({ value = [], onValueChange, disabled = [], days = [0, 1, 2, 3, 4, 5, 6], showAllDays = true, timeIncrements = 30, startTime = 7, endTime = 23, useAmPm = false, mergeAdjacent = true, slotClassName = "bg-muted", className, }: AvailabilityProps) { const [internalValue, setInternalValue] = React.useState(value) // Drag state const dragPreviewTunnel = React.useMemo(() => tunnel(), []) const [activeId, setActiveId] = React.useState(null) const [overDayIndex, setOverDayIndex] = React.useState(null) const [deltaY, setDeltaY] = React.useState(0) const [isDropValid, setIsDropValid] = React.useState(true) const mainContainerRef = React.useRef(null) // Determine which days to render const renderedDays = React.useMemo(() => { if (showAllDays) { return [0, 1, 2, 3, 4, 5, 6] } return days }, [days, showAllDays]) React.useEffect(() => { setInternalValue(value) }, [value]) const updateValue = (newValue: TimeSpan[], shouldMerge = false) => { const finalValue = shouldMerge && mergeAdjacent ? mergeAdjacentSpans(newValue) : newValue setInternalValue(finalValue) onValueChange?.(finalValue) } const handleResize = (id: string, newStart: string, newEnd: string, isComplete = false) => { const newValue = internalValue.map(span => { if (span.id === id) { return { ...span, start_time: newStart, end_time: newEnd } } return span }) updateValue(newValue, isComplete) } const handleCreate = (dayIndex: number, startMinutes: number, endMinutes: number) => { const newSpan: TimeSpan = { id: generateId(), week_day: dayIndex, // Directly use the dayIndex (0-6) start_time: minutesToTime(startMinutes), end_time: minutesToTime(endMinutes), active: true, } updateValue([...internalValue, newSpan], true) } const handleDelete = (id: string) => { updateValue( internalValue.filter(s => s.id !== id), true, ) } const handleMove = (id: string, newStart: string, newEnd: string, newDayIndex: number) => { const newValue = internalValue.map(span => { if (span.id === id) { return { ...span, start_time: newStart, end_time: newEnd, week_day: newDayIndex } } return span }) updateValue(newValue, true) } // Validation helper const validatePlacement = ( span: TimeSpan, targetDayIndex: number, deltaY: number, containerHeight: number, ): { isValid: boolean; newStart: number; duration: number } => { const totalMinutes = (endTime - startTime) * 60 const pixelsPerMinute = containerHeight / totalMinutes const deltaMinutesRaw = deltaY / pixelsPerMinute const deltaMinutes = Math.round(deltaMinutesRaw / timeIncrements) * timeIncrements const originalStart = timeToMinutes(span.start_time) const duration = timeToMinutes(span.end_time) - originalStart const newStart = originalStart + deltaMinutes const newEnd = newStart + duration // Check bounds const dayStartMins = startTime * 60 const dayEndMins = endTime * 60 if (newStart < dayStartMins || newEnd > dayEndMins) { return { isValid: false, newStart, duration } } // Check if day is active (in allowed days list) if (!days.includes(targetDayIndex)) { return { isValid: false, newStart, duration } } // Check collisions with active events const dayEvents = internalValue.filter(e => e.week_day === targetDayIndex && e.id !== span.id) const hasEventOverlap = dayEvents.some(e => { const eStart = timeToMinutes(e.start_time) const eEnd = timeToMinutes(e.end_time) return newStart < eEnd && newEnd > eStart }) if (hasEventOverlap) { return { isValid: false, newStart, duration } } // Check collisions with disabled regions const dayDisabled = disabled.filter(e => e.week_day === targetDayIndex) const hasDisabledOverlap = dayDisabled.some(e => { const eStart = timeToMinutes(e.start_time) const eEnd = timeToMinutes(e.end_time) return newStart < eEnd && newEnd > eStart }) if (hasDisabledOverlap) { return { isValid: false, newStart, duration } } return { isValid: true, newStart, duration } } // DnD Handlers const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8, }, }), ) const handleDragStart = (event: DragStartEvent) => { setActiveId(event.active.id as string) setDeltaY(0) setOverDayIndex(null) setIsDropValid(true) } const handleDragMove = (event: DragMoveEvent) => { setDeltaY(event.delta.y) checkValidity(event.active.id as string, event.over?.id, event.delta.y) } const handleDragOver = (event: DragOverEvent) => { if (event.over) { const dayIndex = parseInt(event.over.id.toString().replace("day-", ""), 10) if (!isNaN(dayIndex)) { setOverDayIndex(dayIndex) } } else { setOverDayIndex(null) } checkValidity(event.active.id as string, event.over?.id, event.delta.y) } const checkValidity = (activeId: string, overId: string | number | undefined, currentDeltaY: number) => { if (!mainContainerRef.current || !overId) { setIsDropValid(false) return } const span = internalValue.find(s => s.id === activeId) if (!span) return const targetDayIndex = parseInt(overId.toString().replace("day-", ""), 10) if (isNaN(targetDayIndex)) { setIsDropValid(false) return } const result = validatePlacement(span, targetDayIndex, currentDeltaY, mainContainerRef.current.clientHeight) setIsDropValid(result.isValid) } const handleDragCancel = () => { setActiveId(null) setOverDayIndex(null) setDeltaY(0) setIsDropValid(true) } const handleDragEnd = (event: DragEndEvent) => { const { active, delta, over } = event setActiveId(null) setOverDayIndex(null) setDeltaY(0) setIsDropValid(true) const span = internalValue.find(s => s.id === active.id) if (!span || !mainContainerRef.current || !over) return const targetDayIndex = parseInt(over.id.toString().replace("day-", ""), 10) if (isNaN(targetDayIndex)) return // Final validation before commit const { isValid, newStart, duration } = validatePlacement( span, targetDayIndex, delta.y, mainContainerRef.current.clientHeight, ) if (!isValid) { // Invalid drop, do nothing (snaps back) return } const newEndVal = newStart + duration handleMove(span.id, minutesToTime(newStart), minutesToTime(newEndVal), targetDayIndex) } const activeSpan = React.useMemo(() => internalValue.find(s => s.id === activeId) || null, [activeId, internalValue]) return (
{/* Header */}
{renderedDays.map(dayIndex => { const isActive = days.includes(dayIndex) return (
{DAYS[dayIndex]}
) })}
{/* Body */}
{/* Time Labels */}
{Array.from({ length: endTime - startTime }).map((_, i) => { const hour = startTime + i return (
{formatDisplayTime(`${hour}:00`, useAmPm)}
) })}
{/* Days Grid */}
{Array.from({ length: endTime - startTime }).map((_, i) => (
))}
{renderedDays.map((dayIndex, i) => { // Check if this day is in the active list const isActive = days.includes(dayIndex) return ( e.week_day === dayIndex)} disabledEvents={disabled.filter(e => e.week_day === dayIndex)} onCreate={handleCreate} onResize={handleResize} onDelete={handleDelete} useAmPm={useAmPm} isDayDisabled={!isActive} slotClassName={slotClassName} /> ) })}
{/* Drag Overlay */} {activeSpan && (
)}
) } interface DayColumnProps { dayIndex: number colIndex: number startTime: number endTime: number timeIncrements: number events: TimeSpan[] disabledEvents?: TimeSpan[] onCreate: (dayIndex: number, start: number, end: number) => void onResize: (id: string, start: string, end: string, isComplete?: boolean) => void onDelete: (id: string) => void useAmPm: boolean isDayDisabled?: boolean slotClassName?: string } function DayColumn({ dayIndex, colIndex, startTime, endTime, timeIncrements, events, disabledEvents = [], onCreate, onResize, onDelete, useAmPm, isDayDisabled = false, slotClassName = "bg-muted", }: DayColumnProps) { const containerRef = React.useRef(null) const context = React.useContext(AvailabilityDragContext) // Make the column a droppable zone for day detection // Only droppable if not disabled const { setNodeRef } = useDroppable({ id: `day-${dayIndex}`, disabled: isDayDisabled, }) const mergedRef = (node: HTMLDivElement | null) => { containerRef.current = node setNodeRef(node) } const { isCreating, creationStart, currentMouseY, totalMinutes, startOffset, sortedConstraints, handlePointerDown } = useCalendarCreation({ containerRef, timeIncrements, startTime, endTime, events, disabledEvents, onCreate, colIndex, isDayDisabled, }) // Calculate ghost position const showGhost = context?.activeId && context.overDayIndex === dayIndex && containerRef.current && !isDayDisabled const ghostStyle = React.useMemo(() => { if (!showGhost || !context?.activeSpan || !containerRef.current) return null const span = context.activeSpan const containerHeight = containerRef.current.clientHeight const pixelsPerMinute = containerHeight / totalMinutes const deltaMinutesRaw = context.deltaY / pixelsPerMinute const deltaMinutes = Math.round(deltaMinutesRaw / timeIncrements) * timeIncrements const originalStart = timeToMinutes(span.start_time) const duration = timeToMinutes(span.end_time) - originalStart // Calculate proposed start const newStart = originalStart + deltaMinutes // Determine visual feedback based on validity // If we are here, context.isDropValid has already determined if this position is valid // We just need to render the ghost at the proposed position (not clamped, so user sees why it fails) return { top: `${((newStart - startOffset) / totalMinutes) * 100}%`, height: `${(duration / totalMinutes) * 100}%`, } }, [ context?.activeId, context?.deltaY, context?.activeSpan, context?.overDayIndex, timeIncrements, totalMinutes, startOffset, showGhost, ]) return (
{/* Full Day Disabled Overlay */} {isDayDisabled && (
)} {/* Disabled Regions */} {disabledEvents.map((disabled, i) => { const startMins = timeToMinutes(disabled.start_time) const endMins = timeToMinutes(disabled.end_time) const duration = endMins - startMins return (
) })} {/* Ghost Element */} {ghostStyle && (
)} {events.map((event, i) => { // Calculate neighbors considering BOTH events and disabled regions for resizing constraints // sortedConstraints contains both. We just need to find neighbors relative to THIS event in that list. // We need a clean list of constraints excluding the event itself const otherConstraints = sortedConstraints.filter(e => e.id !== event.id) const eventStart = timeToMinutes(event.start_time) const eventEnd = timeToMinutes(event.end_time) const prevItem = otherConstraints.filter(e => timeToMinutes(e.end_time) <= eventStart).pop() const nextItem = otherConstraints.find(e => timeToMinutes(e.start_time) >= eventEnd) const minStart = prevItem ? timeToMinutes(prevItem.end_time) : startOffset const maxEnd = nextItem ? timeToMinutes(nextItem.start_time) : endTime * 60 const isDragging = context?.activeId === event.id return ( ) })} {isCreating && creationStart !== null && currentMouseY !== null && (
)}
) } interface DraggableTimeSpanProps { span: TimeSpan startTime: number endTime: number minStart: number maxEnd: number onResize: (id: string, start: string, end: string, isComplete?: boolean) => void onDelete: (id: string) => void useAmPm: boolean timeIncrements: number containerRef: React.RefObject isDragging?: boolean isLocked?: boolean slotClassName?: string } function DraggableTimeSpan({ span, startTime, endTime, minStart, maxEnd, onResize, onDelete, useAmPm, timeIncrements, containerRef, isDragging, isLocked = false, slotClassName = "bg-muted", }: DraggableTimeSpanProps) { const context = React.useContext(AvailabilityDragContext) const { attributes, listeners, setNodeRef } = useDraggable({ id: span.id, data: span, disabled: isLocked, // Disable drag if locked }) const startMinutes = timeToMinutes(span.start_time) const endMinutes = timeToMinutes(span.end_time) const totalMinutes = (endTime - startTime) * 60 const startOffset = startTime * 60 const durationMinutes = endMinutes - startMinutes // Apply transform if dragging const style: React.CSSProperties = { top: `${((startMinutes - startOffset) / totalMinutes) * 100}%`, height: `${(durationMinutes / totalMinutes) * 100}%`, // We hide the original element when dragging, but we render the tunnel content opacity: isDragging ? 0 : isLocked ? 0.6 : 1, // Fade if locked } const handleResizeStart = (e: React.PointerEvent, edge: "top" | "bottom") => { if (isLocked) return e.stopPropagation() e.preventDefault() // Capture pointer for resize drag const target = e.target as HTMLElement target.setPointerCapture(e.pointerId) const initialY = e.clientY const initialStart = startMinutes const initialEnd = endMinutes // Re-calculate local constraints for resize (using passed props or logic) // We can trust the parent passed correct minStart/maxEnd for neighbors const handlePointerMove = (ev: PointerEvent) => { if (!containerRef.current) return const containerHeight = containerRef.current.clientHeight const pixelsPerMinute = containerHeight / totalMinutes const deltaY = ev.clientY - initialY const deltaMinutes = Math.round(deltaY / pixelsPerMinute / timeIncrements) * timeIncrements if (deltaMinutes === 0) return let newStart = initialStart let newEnd = initialEnd if (edge === "top") { newStart += deltaMinutes // For resize we still want clamping to neighbors if (newStart < minStart) newStart = minStart if (newStart >= newEnd - timeIncrements) newStart = newEnd - timeIncrements } else { newEnd += deltaMinutes if (newEnd > maxEnd) newEnd = maxEnd if (newEnd <= newStart + timeIncrements) newEnd = newStart + timeIncrements } // During drag: don't merge (isComplete = false) onResize(span.id, minutesToTime(newStart), minutesToTime(newEnd), false) } const handlePointerUp = (ev: PointerEvent) => { target.releasePointerCapture(ev.pointerId) // Final commit with clamping logic repeated if (containerRef.current) { const containerHeight = containerRef.current.clientHeight const pixelsPerMinute = containerHeight / totalMinutes const deltaY = ev.clientY - initialY const deltaMinutes = Math.round(deltaY / pixelsPerMinute / timeIncrements) * timeIncrements let newStart = initialStart let newEnd = initialEnd if (edge === "top") { newStart += deltaMinutes if (newStart < minStart) newStart = minStart if (newStart >= newEnd - timeIncrements) newStart = newEnd - timeIncrements } else { newEnd += deltaMinutes if (newEnd > maxEnd) newEnd = maxEnd if (newEnd <= newStart + timeIncrements) newEnd = newStart + timeIncrements } onResize(span.id, minutesToTime(newStart), minutesToTime(newEnd), true) } window.removeEventListener("pointermove", handlePointerMove) window.removeEventListener("pointerup", handlePointerUp) } window.addEventListener("pointermove", handlePointerMove) window.addEventListener("pointerup", handlePointerUp) } const canResize = !isLocked const content = ( <> {/* Resize Handle Top - Increased hit area */} {canResize && (
handleResizeStart(e, "top")} /> )} {/* Visual Top Handle */}
{/* Drag Handle Area (middle) - Use dnd-kit listeners here */}
onDelete(span.id) : undefined} /> {/* Resize Handle Bottom - Increased hit area */} {canResize && (
handleResizeStart(e, "bottom")} /> )} {/* Visual Bottom Handle */}
) return ( <>
{content}
{/* Tunnel visual content to overlay if dragging */} {isDragging && context && (
{/* Render content without interactive handlers for the preview */}
)} ) } function TimeSpanCard({ span, useAmPm, duration, onDelete, }: { span: TimeSpan useAmPm: boolean duration?: number onDelete?: () => void }) { const calculatedDuration = duration || (timeToMinutes(span.end_time) - timeToMinutes(span.start_time)) / 60 return (

{formatDisplayTime(span.start_time, useAmPm)}

{" "}

{calculatedDuration.toFixed(1).replace(".0", "")}h

{onDelete && ( )}
{!onDelete && }

{formatDisplayTime(span.end_time, useAmPm)}

) } ``` ### Calendar Year A calendar year component. - Docs: https://www.abui.io/blocks/calendar-year - Registry item: https://www.abui.io/r/calendar-year.json - Type: registry:block - Install: `npx shadcn@latest add @abui/calendar-year` - npm dependencies: dayjs - Registry dependencies: button, tooltip Source: https://github.com/antoniobrandao/abui/blob/master/registry/abui/ui/calendar-year.tsx ```tsx "use client" import * as React from "react" import { Slot } from "@radix-ui/react-slot" import { cva, type VariantProps } from "class-variance-authority" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { cn } from "@/lib/utils" import dayjs from "dayjs" import timezone from "dayjs/plugin/timezone" import utc from "dayjs/plugin/utc" dayjs.extend(timezone) dayjs.extend(utc) // ============================================================================ // Types // ============================================================================ /** * State of a calendar day */ export type DayState = "blocked" | "disabled" /** * Day data structure */ export interface CalendarDay { date: string // YYYY-MM-DD format state?: DayState disabled?: boolean tooltip?: string } /** * Month structure with rows of days */ export interface CalendarMonth { name: string monthIndex: number weekdayLabels: string[] rows: (CalendarDay | null)[][] } // ============================================================================ // Variants // ============================================================================ const calendarYearDayVariants = cva( "cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-semibold transition-all disabled:pointer-events-none disabled:opacity-50 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive relative h-9 w-9 p-0", { variants: { variant: { default: "bg-foreground text-background dark:text-background hover:bg-foreground/90 border border-foreground", "default-success": "bg-green-500 text-background hover:bg-green-600 hover:border-green-600 dark:hover:bg-green-400 dark:hover:border-green-400 border border-green-500", accent: "bg-accent text-background dark:text-background hover:bg-accent/90 border border-accent", destructive: "bg-destructive hover:!bg-destructive text-background focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 border border-destructive", outline: "border bg-background shadow-xs hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", "outline-destructive": "border border-destructive bg-transparent shadow-xs text-destructive bg-transparent", "outline-accent": "border border-accent bg-transparent shadow-xs text-accent bg-transparent", "outline-success": "border border-green-500 bg-transparent shadow-xs text-green-500 bg-transparent", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 border border-secondary", ghost: "bg-transparent text-muted-foreground hover:bg-secondary hover:text-foreground border border-transparent", }, }, defaultVariants: { variant: "outline", }, }, ) // ============================================================================ // Utility Functions // ============================================================================ /** * Generate calendar structure for a year * Uses dayjs locale for month and weekday names * * @param year - Year to generate calendar for * @param timezone - Timezone for date calculations (default: "Europe/Lisbon") * @returns Array of months with rows of days (dates only, no state) */ export function generateYearCalendar(year: number, timezone: string = "Europe/Lisbon"): CalendarMonth[] { const months: CalendarMonth[] = [] // Get weekday labels (Monday to Sunday) using dayjs locale const weekdayLabels: string[] = [] const weekDaysIndexes = [1, 2, 3, 4, 5, 6, 0] // Mon-Sun for (const dayIndex of weekDaysIndexes) { // Create a date that falls on this weekday const sampleDate = dayjs().tz(timezone).day(dayIndex) const dayName = sampleDate.format("dddd") // Capitalize first letter weekdayLabels.push(dayName.charAt(0).toUpperCase() + dayName.slice(1)) } for (let monthIndex = 0; monthIndex < 12; monthIndex++) { const firstDayOfMonth = dayjs().tz(timezone).year(year).month(monthIndex).date(1) const monthName = firstDayOfMonth.format("MMMM") // Capitalize first letter const capitalizedMonthName = monthName.charAt(0).toUpperCase() + monthName.slice(1) const daysInMonth = firstDayOfMonth.daysInMonth() const monthDays: CalendarDay[] = [] // Generate all days for this month (just dates, no business logic) for (let day = 1; day <= daysInMonth; day++) { const date = dayjs().tz(timezone).year(year).month(monthIndex).date(day).format("YYYY-MM-DD") monthDays.push({ date, state: undefined, disabled: false, tooltip: undefined, }) } // Organize days into rows (weeks) const rows: (CalendarDay | null)[][] = [] const monthDaysClone = [...monthDays] while (monthDaysClone.length > 0) { const newRow: (CalendarDay | null)[] = [] for (const dayIndex of weekDaysIndexes) { const testingDay = monthDaysClone[0] if (testingDay && dayjs(testingDay.date).tz(timezone).day() === dayIndex) { newRow.push(testingDay) monthDaysClone.shift() } else { newRow.push(null) } } rows.push(newRow) } months.push({ name: capitalizedMonthName, monthIndex, weekdayLabels, rows, }) } return months } /** * Check if a date is today */ const isDateToday = (date: string, timezone: string): boolean => { return dayjs(date).tz(timezone).isSame(dayjs().tz(timezone), "day") } // ============================================================================ // Root Component (Container) // ============================================================================ function CalendarYear({ className, children, ...props }: React.ComponentProps<"div">) { return (
{children}
) } // ============================================================================ // Content Component (Scrollable Container) // ============================================================================ interface CalendarYearContentProps extends React.ComponentProps<"div"> { scrollToCurrentMonth?: boolean timezone?: string } function CalendarYearContent({ scrollToCurrentMonth = false, timezone = "Europe/Lisbon", className, children, ...props }: CalendarYearContentProps) { const scrollContainerRef = React.useRef(null) const [hasScrolled, setHasScrolled] = React.useState(false) React.useEffect(() => { if (scrollToCurrentMonth && !hasScrolled && scrollContainerRef.current) { const currentMonth = dayjs().tz(timezone).month() const monthClass = `calendar-month-${currentMonth}` const element = scrollContainerRef.current.querySelector(`.${monthClass}`) if (element) { element.scrollIntoView({ behavior: "instant", block: "start" }) setHasScrolled(true) } } }, [scrollToCurrentMonth, hasScrolled, timezone]) return (
{children}
) } // ============================================================================ // Month Component // ============================================================================ interface CalendarYearMonthProps extends React.ComponentProps<"div"> { name: string monthIndex: number } function CalendarYearMonth({ name, monthIndex, className, children, ...props }: CalendarYearMonthProps) { return (

{name}

{children}
) } // ============================================================================ // Weekday Header Component // ============================================================================ interface CalendarYearWeekdayHeaderProps extends Omit, "children"> { labels: string[] } function CalendarYearWeekdayHeader({ labels, className, ...props }: CalendarYearWeekdayHeaderProps) { return (
{labels.map((label, i) => (

{label}

))}
) } // ============================================================================ // Week Component // ============================================================================ function CalendarYearWeek({ className, children, ...props }: React.ComponentProps<"div">) { return (
{children}
) } // ============================================================================ // Day Component // ============================================================================ interface CalendarYearDayProps extends Omit, "children"> { date: string state?: DayState variant?: VariantProps["variant"] disabled?: boolean tooltip?: string timezone?: string asChild?: boolean } const CalendarYearDay = React.forwardRef( ( { date, state, variant = "outline", disabled = false, tooltip, timezone = "Europe/Lisbon", className, asChild = false, ...props }, ref, ) => { const dayNumber = dayjs(date).tz(timezone).format("DD") const isToday = isDateToday(date, timezone) const Comp = asChild ? Slot : "button" // Handle blocked state (not disabled but not clickable) const isBlocked = state === "blocked" const isDisabled = disabled || state === "disabled" const buttonContent = ( {dayNumber} ) if (!isDisabled && !isBlocked && tooltip) { return ( {buttonContent}

{tooltip}

) } return buttonContent }, ) CalendarYearDay.displayName = "CalendarYearDay" // ============================================================================ // Exports // ============================================================================ export { CalendarYear, CalendarYearContent, CalendarYearMonth, CalendarYearWeekdayHeader, CalendarYearWeek, CalendarYearDay, calendarYearDayVariants, } ``` ### Scroll Reveal Content A A block that shows 3 parts of content with a specific image for each part, and a fancy line grow expand during scroll. - Docs: https://www.abui.io/blocks/scroll-reveal-content-a - Registry item: https://www.abui.io/r/scroll-reveal-content-a.json - Type: registry:block - Install: `npx shadcn@latest add @abui/scroll-reveal-content-a` - npm dependencies: motion@12 Source: https://github.com/antoniobrandao/abui/blob/master/registry/abui/marketing/scroll-reveal-content-a.tsx ```tsx "use client" import React from "react" import { useRef } from "react" import { cn } from "@/lib/utils" import Image from "next/image" import { useMotionValueEvent, useScroll } from "motion/react" export const centralColumnStyle = "w-[90%] max-w-[1340px] mx-auto" export const pageYPadding = "py-10 md:py-12 lg:py-20 xl:py-30 2xl:py-40" const defaultTitleClass = "text-2xl md:text-3xl font-semibold mb-2 text-foreground" const defaultDescriptionClass = "text-base md:text-lg font-medium mb-2 text-foreground max-w-[400px] leading-[130%]" const imageClass = "absolute top-0 right-0 ml-auto w-auto h-full object-cover rounded-2xl transition-opacity duration-300" export interface ItemContent { title: string description: string image: { url: string width: number height: number alt: string } } interface Props extends React.ComponentProps<"div"> { contentA: ItemContent contentB: ItemContent contentC: ItemContent titleClass?: string descriptionClass?: string } const ScrollRevealContentA = ({ contentA, contentB, contentC, titleClass = defaultTitleClass, descriptionClass = defaultDescriptionClass, className, ...props }: Props) => { const [scrollProgress, setScrollProgress] = React.useState(0) const ref0 = useRef(null) const { scrollYProgress } = useScroll({ target: ref0, }) useMotionValueEvent(scrollYProgress, "change", () => { // @ts-ignore setScrollProgress(scrollYProgress.current) }) return (
{contentA.image.alt} -1 ? "opacity-100" : "opacity-0")} /> {contentB.image.alt} 0.33 ? "opacity-100" : "opacity-0")} /> {contentC.image.alt} 0.66 ? "opacity-100" : "opacity-0")} />
) } export default ScrollRevealContentA const getBarPercentageHeight = (scrollProgress: number, thresholdStart: number, thresholdEnd: number) => { if (scrollProgress < thresholdStart) { return 0 } if (scrollProgress > thresholdEnd) { return 100 } return ((scrollProgress - thresholdStart) / (thresholdEnd - thresholdStart)) * 100 } const PointItem = ({ active, number, title, description, thresholdStart, thresholdEnd, scrollProgress, }: { active: boolean number: string title: string description: string thresholdStart: number thresholdEnd: number scrollProgress: number }) => { const barHeightPercentage = getBarPercentageHeight(scrollProgress, thresholdStart, thresholdEnd) const isActive = barHeightPercentage > 0 return (

{number}

{title}

{description}

) } ``` ### Animated Chart An animated column chart with spring physics, viewport-triggered animations, and support for dynamic data transitions. - Docs: https://www.abui.io/blocks/animated-chart - Registry item: https://www.abui.io/r/animated-chart.json - Type: registry:block - Install: `npx shadcn@latest add @abui/animated-chart` - npm dependencies: motion@12 Source: https://github.com/antoniobrandao/abui/blob/master/registry/abui/marketing/animated-chart.tsx ```tsx "use client" import * as React from "react" import { useRef, useEffect, useMemo } from "react" import { motion, useInView, useAnimationControls } from "motion/react" import { cn } from "@/lib/utils" interface ColumnData { title: string value: number /** String to prepend before the value (e.g., "$") */ prependString?: string /** String to append after the value (e.g., "%") */ appendString?: string /** Animation duration in seconds */ animationDuration?: number /** Animation delay in seconds */ animationDelay?: number /** ClassName applied to the animated column bar element */ className?: string /** ClassName applied to the column's top border */ topBorderClassName?: string /** ClassName applied to this column's title (overrides global titleClassName) */ titleClassName?: string /** ClassName applied to this column's value (overrides global valueClassName) */ valueClassName?: string } interface AnimatedChartProps extends React.ComponentPropsWithoutRef<"div"> { columns: ColumnData[] maxValue: number /** ClassName applied to all column titles */ titleClassName?: string /** ClassName applied to all column values */ valueClassName?: string /** When true, columns animate from zero whenever data changes. Default: false */ restartOnDataChange?: boolean } interface AnimatedChartColumnProps extends React.ComponentPropsWithoutRef<"div"> { title: string value: number maxValue: number prependString?: string appendString?: string animationDuration: number animationDelay: number columnClassName?: string topBorderClassName?: string /** Global titleClassName from parent */ globalTitleClassName?: string /** Global valueClassName from parent */ globalValueClassName?: string /** Column-specific titleClassName (overrides global) */ columnTitleClassName?: string /** Column-specific valueClassName (overrides global) */ columnValueClassName?: string isInView: boolean isLast: boolean restartTrigger: number } function AnimatedChartColumn({ title, value, maxValue, prependString, appendString, animationDuration, animationDelay, columnClassName, topBorderClassName, globalTitleClassName, globalValueClassName, columnTitleClassName, columnValueClassName, isInView, isLast, restartTrigger, className, ...props }: AnimatedChartColumnProps) { const heightPercentage = (value / maxValue) * 100 const heightPercentageRef = useRef(heightPercentage) heightPercentageRef.current = heightPercentage const barControls = useAnimationControls() const valueControls = useAnimationControls() // Handle animation when in view or when restart is triggered useEffect(() => { const animateFromZero = async () => { // Stop any running animations first barControls.stop() valueControls.stop() // Reset to zero instantly barControls.set({ height: 0 }) valueControls.set({ opacity: 0 }) // Small delay to ensure the reset is rendered before animating await new Promise(resolve => requestAnimationFrame(resolve)) // Animate to target values if (isInView) { barControls.start({ height: `${heightPercentageRef.current}%`, transition: { type: "spring", damping: 25, stiffness: 50, delay: animationDelay, }, }) valueControls.start({ opacity: 1, transition: { delay: animationDelay + animationDuration * 0.5, duration: 0.3, }, }) } } animateFromZero() }, [restartTrigger, isInView, animationDelay, animationDuration, barControls, valueControls]) return (
{/* Title wrapper - fixed position at top of column */}
{title}
{/* Bar container - takes remaining space and aligns bar to bottom */}
{/* Column bar */} {/* Value positioned at top-left inside the bar */} {prependString && `${prependString} `} {value} {appendString && ` ${appendString}`}
) } function AnimatedChart({ columns, maxValue, titleClassName, valueClassName, restartOnDataChange = false, className, ...props }: AnimatedChartProps) { const ref = useRef(null) const isInView = useInView(ref, { once: true, amount: 0.5 }) // Generate a data signature to detect changes const dataSignature = useMemo(() => { return JSON.stringify(columns.map(c => ({ title: c.title, value: c.value }))) }, [columns]) // Track restart trigger - increments when data changes (if restartOnDataChange is true) const restartTriggerRef = useRef(0) const prevDataSignatureRef = useRef(dataSignature) if (restartOnDataChange && prevDataSignatureRef.current !== dataSignature) { restartTriggerRef.current += 1 prevDataSignatureRef.current = dataSignature } return (
{columns.map((column, index) => ( ))}
) } export { AnimatedChart, AnimatedChartColumn } export type { AnimatedChartProps, AnimatedChartColumnProps, ColumnData } ``` ### Squeeze Carousel A canvas-driven news carousel: one wide active card with squeezed companions that stretch on hover and slide into place, with keyboard and screen-reader support and a scroll-snap mobile layout. - Docs: https://www.abui.io/blocks/squeeze-carousel - Registry item: https://www.abui.io/r/squeeze-carousel.json - Type: registry:block - Install: `npx shadcn@latest add @abui/squeeze-carousel` - npm dependencies: @radix-ui/react-slot, @radix-ui/react-use-controllable-state, lucide-react - Registry dependencies: button Source: https://github.com/antoniobrandao/abui/blob/master/registry/abui/marketing/squeeze-carousel.tsx ```tsx "use client" import * as React from "react" import { Slot } from "@radix-ui/react-slot" import { useControllableState } from "@radix-ui/react-use-controllable-state" import { ArrowLeft, ArrowRight } from "lucide-react" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" /** * SqueezeCarousel * * A "what's happening" style news carousel: one wide active card followed by progressively * narrower companions that stretch on hover and slide into place when you navigate. * * Desktop (>= sm): the image strip is a single . Every card is clipped to a rounded * rectangle and its image is cover-fitted into a fixed 16:9 box centred on the card, so a * card looks squeezed rather than scaled. Card widths come from flex fractions of the width * left over after the primary 16:9 card, and every width, gap and offset is tweened with an * ease-out-expo curve when the active index changes. * * Mobile (< sm): a native scroll-snap list of cards with scale, parallax and fade driven by * scroll progress through CSS custom properties. * * Composition: * * * * * What's happening * See the latest from us. * * * * * * * * {renderCopy} * {renderCopy} * */ /* ------------------------------------------------------------------------------------------------- * Types * -----------------------------------------------------------------------------------------------*/ export interface SqueezeCarouselItem { /** Stable identifier, used as the React key. */ id: string /** * Plain-text name of the item. Announced to screen readers ("Item 2 of 8: …") and used as * the default `alt` text of the mobile image. */ title: string /** Image drawn on the desktop canvas. A 16:9 image of at least 1632×918 is recommended. */ src: string /** Image used by the mobile card list. Defaults to `src`. A 336:350 crop is recommended. */ mobileSrc?: string /** Alternative text for the mobile image. Defaults to `title`. Pass "" for decorative images. */ alt?: string /** * Horizontal offset in CSS pixels applied to the image while its card is collapsed. Use it * to keep the focal point of the image inside the narrow column. Positive values move the * image to the left. Default: 0. */ collapsedOffsetX?: number /** * Link opened when the primary (fully revealed) card is clicked and no * `SqueezeCarouselDetailAction` is registered for the item. When an action exists, clicking * the card activates that action instead, so router links and analytics keep working. */ href?: string } export type SqueezeCarouselCrossOrigin = "anonymous" | "use-credentials" export interface SqueezeCarouselNavigateOptions { /** Move keyboard focus to the newly active item's action once it becomes active. */ focus?: boolean } export type SqueezeCarouselRenderItem = (item: SqueezeCarouselItem, index: number) => React.ReactNode /* ------------------------------------------------------------------------------------------------- * Canvas engine * * Framework-agnostic class that owns the . It keeps a list of "cards" (one per visible * column), lays them out from a set of flex fractions, and tweens every card between layouts. * -----------------------------------------------------------------------------------------------*/ const SQUEEZE = { /** Gap before a collapsed 8px sliver. */ smallCardGap: 8, /** Gap before one of the four main columns. */ largeCardGap: 16, /** Width of a collapsed sliver. */ smallCardWidth: 8, /** Extra width given to a hovered sliver. */ smallCardHoverOffset: 3, /** Share of the leftover width taken by columns 0..3 at rest. Column 0 adds this to its 16:9 base. */ flex: [-0.06, 0.61, 0.3, 0.15], /** Shares used for the columns that are not hovered while another one is. */ flexSqueezed: [-0.12, 0.59, 0.28, 0.13], /** Shares used for the hovered column. */ flexStretched: [0, 0.71, 0.4, 0.25], /** Tween duration in ms. */ duration: 1000, /** Tween duration in ms when the user prefers reduced motion. */ reducedMotionDuration: 200, /** Default corner radius of every card. */ borderRadius: 6, /** The primary column is sized from the canvas height at this ratio; images are cover-fitted into it. */ primaryAspectRatio: 16 / 9, /** Cap on the share of the canvas width the primary column may take, so tall canvases keep companions. */ maxPrimaryShare: 0.75, /** Maximum device pixel ratio used for the backing store. */ maxPixelRatio: 2, } as const const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max) const lerp = (from: number, to: number, t: number) => from + (to - from) * t const easeOutExpo = (t: number) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t)) const mod = (value: number, length: number) => ((value % length) + length) % length interface EngineCard { itemIndex: number image: HTMLImageElement x: number width: number leftGap: number collapsedOffsetX: number columnIndex: number startWidth?: number targetWidth?: number startLeftGap?: number targetLeftGap?: number startCollapsedOffsetX?: number targetCollapsedOffsetX?: number } interface SqueezeCanvasEngineOptions { canvas: HTMLCanvasElement items: SqueezeCarouselItem[] /** Item shown in the primary column on first paint. */ initialIndex?: number /** Called when a non-primary column is clicked, with the number of steps to advance. */ onColumnClick: (steps: number) => void /** Called when the primary (fully revealed) column is clicked. */ onPrimaryClick?: (event: MouseEvent) => void prefersReducedMotion?: boolean borderRadius?: number crossOrigin?: SqueezeCarouselCrossOrigin } export class SqueezeCanvasEngine { /** Index of the item currently (or about to be) shown in the primary column. */ activeIndex: number private readonly canvas: HTMLCanvasElement private readonly ctx: CanvasRenderingContext2D private readonly dpr: number private readonly items: SqueezeCarouselItem[] private readonly images: HTMLImageElement[] private readonly collapsedOffsets: number[] private readonly onColumnClick: (steps: number) => void private readonly onPrimaryClick?: (event: MouseEvent) => void private readonly prefersReducedMotion: boolean private readonly borderRadius: number private readonly animationDuration: number private readonly resizeObserver: ResizeObserver | null private cards: EngineCard[] = [] private numSmallCards = 3 private visibleCardsCount = 7 private totalGap = 0 private canvasWidth = 0 private canvasHeight = 0 private primaryBaseWidth = 0 private totalFlexWidth = 0 private imageWidth = 0 private flex: number[] = [...SQUEEZE.flex] private hoveredColumn = -1 private columnIndexOffset = 0 private cardsOffsetX = 0 private startCardsOffsetX = 0 private targetCardsOffsetX = 0 private flexWidthOffset = 0 private startFlexWidthOffset = 0 private targetFlexWidthOffset = 0 private animationStartAt = 0 private animationFrame = 0 private resizeFrame = 0 private disposed = false constructor({ canvas, items, initialIndex = 0, onColumnClick, onPrimaryClick, prefersReducedMotion = false, borderRadius = SQUEEZE.borderRadius, crossOrigin, }: SqueezeCanvasEngineOptions) { const ctx = canvas.getContext("2d") if (!ctx) throw new Error("SqueezeCanvasEngine: 2D canvas context is not available") this.canvas = canvas this.ctx = ctx this.dpr = Math.min(window.devicePixelRatio || 1, SQUEEZE.maxPixelRatio) this.items = items this.onColumnClick = onColumnClick this.onPrimaryClick = onPrimaryClick this.prefersReducedMotion = prefersReducedMotion this.borderRadius = borderRadius this.animationDuration = prefersReducedMotion ? SQUEEZE.reducedMotionDuration : SQUEEZE.duration this.activeIndex = items.length ? mod(initialIndex, items.length) : 0 this.collapsedOffsets = items.map(item => item.collapsedOffsetX ?? 0) this.images = items.map(item => { const image = new Image() if (crossOrigin) image.crossOrigin = crossOrigin image.decoding = "async" image.src = item.src image.onload = this.render return image }) this.numSmallCards = clamp(items.length - 4, 1, 3) this.visibleCardsCount = 4 + this.numSmallCards this.totalGap = SQUEEZE.smallCardGap * this.numSmallCards + 3 * SQUEEZE.largeCardGap this.canvas.addEventListener("pointermove", this.handlePointerMove) this.canvas.addEventListener("pointerleave", this.handlePointerLeave) this.canvas.addEventListener("click", this.handleClick) document.addEventListener("visibilitychange", this.render) this.resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { cancelAnimationFrame(this.resizeFrame) this.resizeFrame = requestAnimationFrame(this.resize) }) this.resizeObserver?.observe(this.canvas) this.measure() this.cards = Array.from({ length: this.visibleCardsCount }, (_, column) => { const itemIndex = mod(this.activeIndex + column, Math.max(items.length, 1)) return { itemIndex, image: this.images[itemIndex], x: 0, width: this.getCardWidth(column), leftGap: column < 4 ? SQUEEZE.largeCardGap : SQUEEZE.smallCardGap, collapsedOffsetX: column === 0 ? 0 : this.collapsedOffsets[itemIndex], columnIndex: column, } }) this.render() } /** Advance by `delta` columns (negative values go backwards). */ navigate = (delta: number) => { if (this.disposed || !this.items.length || delta === 0) return this.activeIndex = mod(this.activeIndex + delta, this.items.length) if (delta > 0) this.goToNext(delta) else this.goToPrevious(-delta) } /** Jump to an item, taking the shortest direction around the loop. */ setActiveIndex = (index: number) => { if (!this.items.length) return const target = mod(index, this.items.length) const forward = mod(target - this.activeIndex, this.items.length) const backward = mod(this.activeIndex - target, this.items.length) if (forward === 0) return this.navigate(forward <= backward ? forward : -backward) } dispose = () => { this.disposed = true cancelAnimationFrame(this.animationFrame) cancelAnimationFrame(this.resizeFrame) this.resizeObserver?.disconnect() this.canvas.removeEventListener("pointermove", this.handlePointerMove) this.canvas.removeEventListener("pointerleave", this.handlePointerLeave) this.canvas.removeEventListener("click", this.handleClick) document.removeEventListener("visibilitychange", this.render) this.images.forEach(image => { image.onload = null image.src = "" }) this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight) this.canvas.width = 0 this.canvas.height = 0 this.canvas.style.removeProperty("cursor") } /* ---- layout ---------------------------------------------------------------------------------- */ private measure() { const { width, height } = this.canvas.getBoundingClientRect() this.canvas.width = Math.round(width * this.dpr) this.canvas.height = Math.round(height * this.dpr) this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0) this.canvasWidth = width this.canvasHeight = height const fixedWidth = this.totalGap + SQUEEZE.smallCardWidth * this.numSmallCards this.primaryBaseWidth = Math.min( height * SQUEEZE.primaryAspectRatio, Math.max(0, (width - fixedWidth) * SQUEEZE.maxPrimaryShare), ) this.totalFlexWidth = width - this.primaryBaseWidth - this.totalGap - SQUEEZE.smallCardWidth * this.numSmallCards this.imageWidth = this.primaryBaseWidth + this.totalFlexWidth * SQUEEZE.flexStretched[0] } private resize = () => { if (this.disposed) return this.measure() cancelAnimationFrame(this.animationFrame) for (const card of this.cards) { card.width = this.getCardWidth(card.columnIndex) card.leftGap = card.columnIndex < 4 ? SQUEEZE.largeCardGap : SQUEEZE.smallCardGap } this.hoveredColumn = -1 this.updateFlex() } private getCardWidth(column: number) { if (column < 0 || column > 3) { const hoverOffset = this.hoveredColumn === column ? this.targetFlexWidthOffset : 0 return SQUEEZE.smallCardWidth + hoverOffset } const available = this.totalFlexWidth - this.targetFlexWidthOffset if (column === 0) return this.primaryBaseWidth + available * this.flex[0] return available * this.flex[column] } private updateFlex() { const hovered = this.hoveredColumn if (hovered >= 0 && hovered <= 3 && !this.prefersReducedMotion) { this.flex = SQUEEZE.flex.map((_, column) => column === hovered ? SQUEEZE.flexStretched[column] : SQUEEZE.flexSqueezed[column], ) } else { this.flex = [...SQUEEZE.flex] } const clickable = hovered > 0 || (hovered === 0 && !!this.onPrimaryClick) this.canvas.style.cursor = clickable ? "pointer" : "" this.startAnimation() } /* ---- navigation ------------------------------------------------------------------------------ */ private goToPrevious(steps: number) { for (let i = 0; i < steps; i += 1) { const itemIndex = mod(this.cards[0].itemIndex - 1, this.items.length) this.cards.unshift({ itemIndex, image: this.images[itemIndex], x: 0, width: SQUEEZE.smallCardWidth, leftGap: SQUEEZE.largeCardGap, collapsedOffsetX: 0, columnIndex: 0, }) } this.cards.forEach((card, index) => { card.columnIndex = index + this.columnIndexOffset }) this.startAnimation(steps) } private goToNext(steps: number) { for (let i = 0; i < steps; i += 1) { const itemIndex = mod(this.cards[this.cards.length - 1].itemIndex + 1, this.items.length) this.cards.push({ itemIndex, image: this.images[itemIndex], x: 0, width: SQUEEZE.smallCardWidth, leftGap: SQUEEZE.smallCardGap, collapsedOffsetX: this.collapsedOffsets[itemIndex], columnIndex: 0, }) } this.columnIndexOffset -= steps this.cards.forEach((card, index) => { card.columnIndex = index + this.columnIndexOffset }) this.startAnimation() } /* ---- animation ------------------------------------------------------------------------------- */ private startAnimation(prependedCards = 0) { const slot = SQUEEZE.smallCardWidth + SQUEEZE.largeCardGap this.startCardsOffsetX = this.cardsOffsetX - prependedCards * slot this.targetCardsOffsetX = this.columnIndexOffset * slot this.startFlexWidthOffset = this.flexWidthOffset this.targetFlexWidthOffset = !this.prefersReducedMotion && this.hoveredColumn > 3 ? SQUEEZE.smallCardHoverOffset : 0 for (const card of this.cards) { const column = card.columnIndex card.startCollapsedOffsetX = card.collapsedOffsetX card.targetCollapsedOffsetX = column === 0 ? 0 : this.collapsedOffsets[card.itemIndex] card.startWidth = card.width card.targetWidth = this.getCardWidth(column) card.startLeftGap = card.leftGap card.targetLeftGap = column < 4 ? SQUEEZE.largeCardGap : SQUEEZE.smallCardGap } this.animationStartAt = performance.now() cancelAnimationFrame(this.animationFrame) this.animationFrame = requestAnimationFrame(this.animate) } private animate = (now: number) => { const progress = Math.min((now - this.animationStartAt) / this.animationDuration, 1) const eased = easeOutExpo(progress) this.cardsOffsetX = lerp(this.startCardsOffsetX, this.targetCardsOffsetX, eased) this.flexWidthOffset = lerp(this.startFlexWidthOffset, this.targetFlexWidthOffset, eased) for (const card of this.cards) { card.collapsedOffsetX = lerp( card.startCollapsedOffsetX ?? card.collapsedOffsetX, card.targetCollapsedOffsetX ?? card.collapsedOffsetX, eased, ) card.width = lerp(card.startWidth ?? card.width, card.targetWidth ?? card.width, eased) card.leftGap = lerp(card.startLeftGap ?? card.leftGap, card.targetLeftGap ?? card.leftGap, eased) } this.render() if (eased < 1) { this.animationFrame = requestAnimationFrame(this.animate) } else { this.finishAnimation() } } private finishAnimation() { const first = this.cards.findIndex(card => card.columnIndex === 0) this.cards = this.cards.slice(Math.max(first, 0), Math.max(first, 0) + this.visibleCardsCount) this.columnIndexOffset = 0 this.cardsOffsetX = 0 this.render() } /* ---- rendering ------------------------------------------------------------------------------- */ private render = () => { if (this.disposed) return this.cards.forEach((card, index) => { card.x = index === 0 ? this.cardsOffsetX : this.cards[index - 1].x + this.cards[index - 1].width + card.leftGap }) this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight) for (const card of this.cards) { if (card.x + card.width < 0 || card.x > this.canvasWidth) continue this.renderCard(card) } } private renderCard({ image, width, x, collapsedOffsetX }: EngineCard) { if (!image.complete || image.naturalWidth === 0 || width <= 0) return const { ctx, canvasHeight, imageWidth } = this ctx.save() this.clipRoundedColumn(x, width) // Cover-fit the image into the fixed image box, then centre that box on the card. const imageRatio = image.naturalWidth / image.naturalHeight let drawWidth: number let drawHeight: number let boxOffsetX: number let boxOffsetY: number if (imageRatio > imageWidth / canvasHeight) { drawHeight = canvasHeight drawWidth = canvasHeight * imageRatio boxOffsetX = (imageWidth - drawWidth) / 2 boxOffsetY = 0 } else { drawWidth = imageWidth drawHeight = imageWidth / imageRatio boxOffsetX = 0 boxOffsetY = (canvasHeight - drawHeight) / 2 } const drawX = boxOffsetX + (x + width / 2 - drawWidth / 2) - collapsedOffsetX ctx.drawImage(image, drawX, boxOffsetY, drawWidth, drawHeight) ctx.restore() } private clipRoundedColumn(x: number, width: number) { const height = this.canvasHeight const radius = clamp(this.borderRadius, 0, Math.min(width, height) / 2) const path = new Path2D() path.moveTo(x + radius, 0) path.lineTo(x + width - radius, 0) path.arcTo(x + width, 0, x + width, radius, radius) path.lineTo(x + width, height - radius) path.arcTo(x + width, height, x + width - radius, height, radius) path.lineTo(x + radius, height) path.arcTo(x, height, x, height - radius, radius) path.lineTo(x, radius) path.arcTo(x, 0, x + radius, 0, radius) path.closePath() this.ctx.clip(path) } /* ---- pointer --------------------------------------------------------------------------------- */ private columnAt(offsetX: number) { return this.cards.find(card => card.x <= offsetX && offsetX <= card.x + card.width)?.columnIndex ?? -1 } private handlePointerMove = (event: PointerEvent) => { if (event.pointerType === "touch") return const column = this.columnAt(event.offsetX) if (column >= 0 && column !== this.hoveredColumn) { this.hoveredColumn = column this.updateFlex() } } private handlePointerLeave = () => { if (this.hoveredColumn === -1) return this.hoveredColumn = -1 this.updateFlex() } private handleClick = (event: MouseEvent) => { const column = this.columnAt(event.offsetX) if (column > 0) this.onColumnClick(column) else if (column === 0) this.onPrimaryClick?.(event) } } /* ------------------------------------------------------------------------------------------------- * Hooks * -----------------------------------------------------------------------------------------------*/ function usePrefersReducedMotion() { return React.useSyncExternalStore( onChange => { const query = window.matchMedia("(prefers-reduced-motion: reduce)") query.addEventListener("change", onChange) return () => query.removeEventListener("change", onChange) }, () => window.matchMedia("(prefers-reduced-motion: reduce)").matches, () => false, ) } /** True once the element has come within `rootMargin` of the viewport. Never goes back to false. */ function useNearViewport(ref: React.RefObject, rootMargin = "3000px 0px") { const [isNear, setIsNear] = React.useState(false) React.useEffect(() => { const element = ref.current if (!element || isNear) return if (typeof IntersectionObserver === "undefined") { setIsNear(true) return } const observer = new IntersectionObserver( entries => { if (entries.some(entry => entry.isIntersecting)) { setIsNear(true) observer.disconnect() } }, { rootMargin }, ) observer.observe(element) return () => observer.disconnect() }, [ref, rootMargin, isNear]) return isNear } function composeRefs(...refs: Array | undefined>): React.RefCallback { return node => { for (const ref of refs) { if (typeof ref === "function") ref(node) else if (ref) (ref as React.MutableRefObject).current = node } } } /* ------------------------------------------------------------------------------------------------- * Context * -----------------------------------------------------------------------------------------------*/ interface SqueezeCarouselContextValue { items: SqueezeCarouselItem[] count: number activeIndex: number activeIndexRef: React.MutableRefObject goTo: (index: number, options?: SqueezeCarouselNavigateOptions) => void goToNext: (steps?: number, options?: SqueezeCarouselNavigateOptions) => void goToPrevious: (steps?: number, options?: SqueezeCarouselNavigateOptions) => void engineRef: React.MutableRefObject actionRefs: React.MutableRefObject> titleId: string borderRadius: number imageHeight?: number crossOrigin?: SqueezeCarouselCrossOrigin prefersReducedMotion: boolean onPrimaryClick: (event: MouseEvent) => void } const SqueezeCarouselContext = React.createContext(null) export function useSqueezeCarousel() { const context = React.useContext(SqueezeCarouselContext) if (!context) throw new Error("SqueezeCarousel components must be used within ") return context } interface SqueezeCarouselItemContextValue { item: SqueezeCarouselItem index: number /** Which list this item is rendered in. Only the desktop details take part in focus management. */ surface: "details" | "mobile" } const SqueezeCarouselItemContext = React.createContext(null) export function useSqueezeCarouselItem() { const context = React.useContext(SqueezeCarouselItemContext) if (!context) { throw new Error( "SqueezeCarousel item components must be used within or ", ) } return context } /* ------------------------------------------------------------------------------------------------- * Root * -----------------------------------------------------------------------------------------------*/ export interface SqueezeCarouselProps extends React.ComponentProps<"section"> { /** Items to show. Keep the array reference stable (module scope or useMemo) to avoid re-creating the canvas. */ items: SqueezeCarouselItem[] /** Controlled active item index. */ activeIndex?: number /** Initial active item index for uncontrolled usage. @default 0 */ defaultActiveIndex?: number /** Called whenever the active index changes, in both controlled and uncontrolled mode. */ onActiveIndexChange?: (index: number) => void /** Corner radius of the cards in CSS pixels. Also exposed as `--squeeze-carousel-radius`. @default 6 */ borderRadius?: number /** * Height of the images as a percentage of the small viewport height (`svh`), for both the * desktop canvas and the mobile cards. For example `50` makes the images exactly 50svh tall. * When omitted the canvas keeps a 1232:460 aspect ratio and mobile cards 336:350. */ imageHeight?: number /** * Called when the primary (fully revealed) card is clicked. Defaults to activating the item's * `SqueezeCarouselDetailAction` (or opening `item.href`), with modifier keys opening a new tab. */ onActiveItemClick?: (item: SqueezeCarouselItem, index: number, event: MouseEvent) => void /** `crossOrigin` used when loading canvas images. Only needed if you read the canvas pixels back. */ crossOrigin?: SqueezeCarouselCrossOrigin /** Text announced to screen readers when the active item changes. */ announce?: (item: SqueezeCarouselItem, index: number, count: number) => string } const defaultAnnounce = (item: SqueezeCarouselItem, index: number, count: number) => `Item ${index + 1} of ${count}: ${item.title}` function SqueezeCarousel({ items, activeIndex: activeIndexProp, defaultActiveIndex = 0, onActiveIndexChange, borderRadius = SQUEEZE.borderRadius, imageHeight, onActiveItemClick, crossOrigin, announce = defaultAnnounce, className, style, children, ...props }: SqueezeCarouselProps) { const count = items.length const [activeIndexState, setActiveIndexState] = useControllableState({ prop: activeIndexProp, defaultProp: defaultActiveIndex, onChange: onActiveIndexChange, caller: "SqueezeCarousel", }) const activeIndex = count ? mod(activeIndexState, count) : 0 const titleId = React.useId() const engineRef = React.useRef(null) const actionRefs = React.useRef(new Map()) const activeIndexRef = React.useRef(activeIndex) activeIndexRef.current = activeIndex const prefersReducedMotion = usePrefersReducedMotion() const [focusRequest, setFocusRequest] = React.useState(0) const navigateBy = React.useCallback( (delta: number, options?: SqueezeCarouselNavigateOptions) => { if (!count) return if (delta !== 0) { engineRef.current?.navigate(delta) setActiveIndexState(previous => mod((previous ?? 0) + delta, count)) } if (options?.focus) setFocusRequest(request => request + 1) }, [count, setActiveIndexState], ) const goToNext = React.useCallback( (steps = 1, options?: SqueezeCarouselNavigateOptions) => navigateBy(Math.abs(steps), options), [navigateBy], ) const goToPrevious = React.useCallback( (steps = 1, options?: SqueezeCarouselNavigateOptions) => navigateBy(-Math.abs(steps), options), [navigateBy], ) const goTo = React.useCallback( (index: number, options?: SqueezeCarouselNavigateOptions) => { if (!count) return const target = mod(index, count) const current = activeIndexRef.current const forward = mod(target - current, count) const backward = mod(current - target, count) navigateBy(forward <= backward ? forward : -backward, options) }, [count, navigateBy], ) // Keep the canvas in sync with controlled updates coming from outside. React.useEffect(() => { const engine = engineRef.current if (engine && engine.activeIndex !== activeIndex) engine.setActiveIndex(activeIndex) }, [activeIndex]) // Move focus to the active action after keyboard navigation. React.useEffect(() => { if (focusRequest === 0) return actionRefs.current.get(activeIndex)?.focus() }, [focusRequest, activeIndex]) // Clicking the primary card behaves like clicking the item's action. const onActiveItemClickRef = React.useRef(onActiveItemClick) onActiveItemClickRef.current = onActiveItemClick const onPrimaryClick = React.useCallback( (event: MouseEvent) => { const index = activeIndexRef.current const item = items[index] if (!item) return if (onActiveItemClickRef.current) { onActiveItemClickRef.current(item, index, event) return } const action = actionRefs.current.get(index) const href = action?.getAttribute("href") ?? item.href if (event.metaKey || event.ctrlKey || event.shiftKey) { if (href) window.open(href, "_blank", "noopener") return } if (action) action.click() else if (href) window.location.assign(href) }, [items], ) const value = React.useMemo( () => ({ items, count, activeIndex, activeIndexRef, goTo, goToNext, goToPrevious, engineRef, actionRefs, titleId, borderRadius, imageHeight, crossOrigin, prefersReducedMotion, onPrimaryClick, }), [ items, count, activeIndex, goTo, goToNext, goToPrevious, titleId, borderRadius, imageHeight, crossOrigin, prefersReducedMotion, onPrimaryClick, ], ) const activeItem = items[activeIndex] return (
{children}
{activeItem ? announce(activeItem, activeIndex, count) : null}
) } /* ------------------------------------------------------------------------------------------------- * Header * -----------------------------------------------------------------------------------------------*/ export type SqueezeCarouselHeaderProps = React.ComponentProps<"header"> function SqueezeCarouselHeader({ className, ...props }: SqueezeCarouselHeaderProps) { return (
) } export type SqueezeCarouselHeadingProps = React.ComponentProps<"div"> function SqueezeCarouselHeading({ className, ...props }: SqueezeCarouselHeadingProps) { return (
) } export interface SqueezeCarouselTitleProps extends React.ComponentProps<"h2"> { asChild?: boolean } const headingLgClassName = "max-w-[43ch] text-pretty text-[1.375rem] font-light leading-[1.2] tracking-[-0.01em] sm:text-[1.75rem] sm:leading-[1.1] lg:text-[2rem] lg:leading-[1.07] lg:tracking-[-0.02em]" function SqueezeCarouselTitle({ asChild, className, ...props }: SqueezeCarouselTitleProps) { const { titleId } = useSqueezeCarousel() const Comp = asChild ? Slot : "h2" return ( ) } export interface SqueezeCarouselDescriptionProps extends React.ComponentProps<"p"> { asChild?: boolean } function SqueezeCarouselDescription({ asChild, className, ...props }: SqueezeCarouselDescriptionProps) { const Comp = asChild ? Slot : "p" return ( ) } /* ------------------------------------------------------------------------------------------------- * Controls * -----------------------------------------------------------------------------------------------*/ export type SqueezeCarouselControlsProps = React.ComponentProps<"div"> function SqueezeCarouselControls({ className, ...props }: SqueezeCarouselControlsProps) { return (
) } export type SqueezeCarouselNavButtonProps = React.ComponentProps function useNavKeyDown(onKeyDown?: React.KeyboardEventHandler) { const { goToNext, goToPrevious } = useSqueezeCarousel() return React.useCallback( (event: React.KeyboardEvent) => { onKeyDown?.(event) if (event.defaultPrevented) return if (event.key === "ArrowLeft") { event.preventDefault() goToPrevious() } else if (event.key === "ArrowRight") { event.preventDefault() goToNext() } }, [onKeyDown, goToNext, goToPrevious], ) } const navButtonClassName = "size-10 rounded-md [&_svg]:size-4" function SqueezeCarouselPrevious({ className, children, onClick, onKeyDown, ...props }: SqueezeCarouselNavButtonProps) { const { goToPrevious } = useSqueezeCarousel() const handleKeyDown = useNavKeyDown(onKeyDown) return ( ) } function SqueezeCarouselNext({ className, children, onClick, onKeyDown, ...props }: SqueezeCarouselNavButtonProps) { const { goToNext } = useSqueezeCarousel() const handleKeyDown = useNavKeyDown(onKeyDown) return ( ) } /* ------------------------------------------------------------------------------------------------- * Canvas (desktop image strip) * -----------------------------------------------------------------------------------------------*/ export type SqueezeCarouselCanvasProps = React.ComponentProps<"canvas"> function SqueezeCarouselCanvas({ className, ref, ...props }: SqueezeCarouselCanvasProps) { const { items, engineRef, activeIndexRef, goToNext, borderRadius, imageHeight, crossOrigin, prefersReducedMotion, onPrimaryClick, } = useSqueezeCarousel() const canvasRef = React.useRef(null) const isNear = useNearViewport(canvasRef) const goToNextRef = React.useRef(goToNext) goToNextRef.current = goToNext const onPrimaryClickRef = React.useRef(onPrimaryClick) onPrimaryClickRef.current = onPrimaryClick React.useEffect(() => { const canvas = canvasRef.current if (!canvas || !isNear || items.length === 0) return const engine = new SqueezeCanvasEngine({ canvas, items, initialIndex: activeIndexRef.current, onColumnClick: steps => goToNextRef.current(steps), onPrimaryClick: event => onPrimaryClickRef.current(event), prefersReducedMotion, borderRadius, crossOrigin, }) engineRef.current = engine return () => { engine.dispose() if (engineRef.current === engine) engineRef.current = null } }, [items, isNear, prefersReducedMotion, borderRadius, crossOrigin, engineRef, activeIndexRef]) return (