Waterfall (Gantt) view — horizontal bars on a date-range axis.
Each item is rendered as a row with a horizontal bar whose left edge corresponds to its start date and width corresponds to its duration. Optionally draws SVG connector lines between dependent items using orthogonal (right-angle) routing — the industry-standard approach for Gantt dependency arrows.
Features
- Multi-zoom:
:day,:week,:monthgranularity - Today marker (vertical line)
- Non-working day shading via
day_markers - Progress indicator (fill percentage via
extra.progress_pct) - Milestones (zero-duration items rendered as diamonds)
- Orthogonal dependency connectors with arrow heads
- Grouping via
extra.groupfield - Custom item rendering via
:itemslot
Data mapping
Waterfall uses the standard PhoenixLiveGantt.Task struct:
| Waterfall concept | Event field |
|---|---|
| Task name | title |
| Start date | start (Date) |
| End date | end (Date, exclusive) |
| Duration | Computed from start/end |
| Status/color | color, status |
| Progress | extra.progress_pct (0–100) |
| Group/phase | extra.group or category |
| Assignee | extra.assignee |
| Milestone | When start == end (zero duration) |
Connectors are passed separately as a list of
%{from: event_id, to: event_id} maps.
Coordinate system
Everything is pixel-based against a fixed content width
(total_days × day_width_px + 2 × axis_pad, where axis_pad is a fixed
16px margin on each side that gives edge connectors room — see
axis_pad_px/0). This keeps bar positions, grid columns, today marker, and
connector arrows aligned no matter the flex context.
Summary
Functions
The fixed horizontal margin (px) reserved on EACH side of the time axis so a
connector exiting/entering a task at the very edge of the window has room to
draw instead of clipping. The natural content width is
total_days × day_width_px + 2 × axis_pad_px(). A consumer computing a
fit-to-width day_width_px from a measured viewport should subtract
2 × axis_pad_px() first.
The default pixels-per-day for a zoom level — :hour 720, :day 40,
:week 24, :month 8. Use it as the floor when computing a fit-to-width
day_width_px override, so fitting only ever widens (and a long chart still
scrolls at its natural density).
Renders a Gantt / waterfall chart — horizontal task bars on a time axis with orthogonal dependency connectors, milestones, sub-projects, and a built-in popover.
A Phoenix.LiveView.JS command that scrolls a chart's timeline back to its
start (leftmost column). Pair it with a "home"/"fit" button whose server
handler refits the window — the server can't move the scroll, and the built-in
scroll-to-today only fires when the today marker is in view, so a refit that
doesn't include today would otherwise leave the timeline scrolled to a stale
spot. Requires enable_hooks + the matching id (the LgAutoScroll hook
listens for the dispatched lg:scroll-start).
Toggles an id in an expanded set — convenience for on_toggle_expand
handlers so consumers don't re-write the member?/put/delete boilerplate.
Functions
@spec axis_pad_px() :: non_neg_integer()
The fixed horizontal margin (px) reserved on EACH side of the time axis so a
connector exiting/entering a task at the very edge of the window has room to
draw instead of clipping. The natural content width is
total_days × day_width_px + 2 × axis_pad_px(). A consumer computing a
fit-to-width day_width_px from a measured viewport should subtract
2 × axis_pad_px() first.
@spec default_day_width_px(atom()) :: pos_integer()
The default pixels-per-day for a zoom level — :hour 720, :day 40,
:week 24, :month 8. Use it as the floor when computing a fit-to-width
day_width_px override, so fitting only ever widens (and a long chart still
scrolls at its natural density).
Renders a Gantt / waterfall chart — horizontal task bars on a time axis with orthogonal dependency connectors, milestones, sub-projects, and a built-in popover.
Pass a list of PhoenixLiveGantt.Task structs as events and a Date.Range as
date_range; everything else is optional. Each attribute below documents its
own default and behavior. The smallest useful call:
<PhoenixLiveGantt.gantt events={@tasks} date_range={@range} />Note: no stylesheet ships — your app's Tailwind must scan this library as a
content source (see the README), and the JS hooks (priv/static/assets/ phoenix_live_gantt.js) must be registered for the popover / scroll-to-today.
Attributes
events(:list) - Defaults to[].date_range(Date.Range) (required)window_start(NaiveDateTime) - Optional sub-day positioning origin. Whenwindow_start/window_endare bothNaiveDateTimes, the window becomes the axis: it drives the start/end instants, the columns, event partition (in-window vs the← N earlier / N later →edge counts), and the today marker — instead ofdate_range's midnight-to-midnight span. Useful at:hour/:min15/:min5zoom to begin ~1 column before the first task rather than at midnight (a wall of empty pre-task columns). Snapwindow_startto a column-slot boundary so labels land on round clock times.date_rangeremains required as the base axis and is used whenever the window is absent or non-positive, so keep it covering the same span. Defaults tonil.window_end(NaiveDateTime) - Sub-day positioning end instant. Seewindow_start. Defaults tonil.zoom(:atom) - Defaults to:week.connectors(:list) - Defaults to[].day_markers(:list) - Non-working / highlighted day ranges, shaded in the grid. Each is a%{start_date: Date.t(), end_date: Date.t() | nil, available: boolean()}(anend_dateofnilmeans a single day;available: falseshades it as non-working). Defaults to[].day_width_px(:integer) - Override the per-zoom pixels-per-day. The natural content width istotal_days * day_width_px + 2 * axis_pad_px()(the pad reserves room for edge connectors); that is the scrollmin-width. The chart is responsive and fills wider containers on its own (horizontal coords are percentages), so use this only to tune density / the scroll threshold.niluses the zoom default. Defaults tonil.min_bar_px(:integer) - Minimum rendered width (px) for a non-milestone bar. Default0— bars reflect their TRUE duration, so a task too short to show at the current zoom is a hairline (or vanishes) until you zoom in. Set e.g.4to floor every bar to a visible/clickable sliver at the cost of overstating very short tasks' spans (connectors still attach to the rendered edge). A zero-DURATION task is always a milestone diamond regardless of this value. Defaults to0.today(:any) - Today's date (aDate), or aDateTime/NaiveDateTimefor a precise 'now' line — recommended at:hourzoom, where the marker lands at the exact time and the current-hour column highlights. Defaults toDate.utc_today(). Defaults tonil.row_height(:string) - Defaults to"2.5rem".label_width(:string) - Defaults to"14rem".on_event_click(:any) - Defaults tonil.expanded(:any) - Which sub-projects are expanded (children visible). AMapSetor list of expanded event ids,:allto expand everything, ornilfor all collapsed. Collapsed parents' children are hidden and connectors retarget to the visible ancestor. Defaults tonil.on_toggle_expand(:any) - phx-click event name fired when a sub-project chevron is toggled. The handler receives the event id under the"event-id"param key (hyphen). Update yourexpandedset in response — seePhoenixLiveGantt.toggle_expanded/2. Defaults tonil.show_progress(:boolean) - Defaults totrue.show_today(:boolean) - Defaults totrue.show_today_edge(:boolean) - Show the floating directional← Today/Today →pill when today is off-screen. Independent ofshow_today(which controls the in-range today line), so you can keep the line but drop the off-screen hint. Defaults totrue.show_connectors(:boolean) - Defaults totrue.tiny_bar_px(:integer) - When a bar renders narrower than this many SCREEN pixels, a small fixed-size down-triangle marker appears at the task's start to signal a too-small-to-see task. The decision is pure CSS — a container query against the bar's rendered width — so it's server-emitted, correct against the responsive fill + zoom, instant on first paint, and re-resolves on resize with no JavaScript. (Clicking the marker opens the same popover, which does needenable_hooks.) Set0to disable. Bars themselves stay at their true width — seemin_bar_px. Assumes a uniform value across charts sharing a page. Defaults to5.avoid_collisions(:boolean) - When true, connector trunks are shifted to avoid crossing unrelated bars. Turn off for very large Gantts or when you prefer strict bus alignment. Defaults totrue.label_background(:atom) - How connector labels render. :halo (default) paints each glyph with a base-100 outline so the line shows between letters. :rect draws a solid base-100 rectangle behind the text — stronger contrast over bars but can leave visible gaps in the line around narrow words. Defaults to:halo. Must be one of:halo, or:rect.connector_color_class(:string) - Defaults to"text-base-content/50".connector_stroke_width(:float) - Defaults to1.5.connector_opacity(:float) - Defaults to1.0.connector_dasharray(:string) - Defaults to"none".critical_color_class(:string) - Defaults to"text-primary".critical_stroke_width(:float) - Defaults to2.25.invalid_color_class(:string) - Defaults to"text-error".invalid_stroke_width(:float) - Defaults to2.0.invalid_dasharray(:string) - Defaults to"4 3".connector_elbow_px(:integer) - Defaults to10.connector_bar_clearance_px(:integer) - Defaults to10.bus_split_offset_pct(:integer) - Used bybus_attach_mode={:type_zoned}. When a bar side has both incoming and outgoing arrows, this is the % offset from the bar's top edge for outgoing attachment (incoming mirrors). Default 40 → 40%/60% split. Set to 50 to disable the split. Defaults to40.bus_attach_mode(:atom) - How arrow endpoints attach to a bar edge when multiple arrows touch it::smart(default) — each arrow's attach y depends on the OTHER end's row. Outgoing arrows going DOWN attach to the bar bottom; outgoing arrows going UP attach to the bar top. Incoming arrows from ABOVE land on the upper-middle of the bar; from BELOW land on the lower-middle. Up to 4 designated y positions per side. If only one of these positions is in use → collapses to bar center.:type_zoned— outgoing always at top, incoming always at bottom (regardless of direction). Usesbus_split_offset_pct.:center— disable splits entirely; everything attaches at the bar center. Per-task override: setextra.bus_attach_modeon an event to one of these atoms.
Defaults to
:smart. Must be one of:smart,:type_zoned, or:center.bus_attach_inner_pct(:integer) - Smart mode only. % offset from bar edge for both attach positions. Default 40 → split at 40%/60% of bar height. Smart mode picks one for outgoing (by majority outgoing direction) and the opposite for incoming. Defaults to40.bus_stagger_outgoing_px(:integer) - Stagger trunk x by this many px per lane for arrows in the SAME outgoing bus (multiple outgoing from one source on one side). Default 0 = merged (single trunk). Set to 3-5 to fan out each outgoing arrow into its own visible lane. Per-task override viaextra.bus_stagger_outgoing_px. Defaults to0.bus_stagger_incoming_px(:integer) - Stagger trunk x by this many px per lane for arrows in the SAME incoming bus (multiple incoming to one target on one side). Default 0 = merged. Per-task override viaextra.bus_stagger_incoming_px. Defaults to0.bus_stagger_corner_clearance_px(:integer) - When stagger is active and a bar side has multiple arrows, lanes are distributed evenly across the bar's FLAT region (excluding rounded corners) so no arrow emerges from a corner. This sets the corner radius to avoid; default 4 matches Tailwind'srounded(4px) on the defaultbar_class. Set to 0 if your bar isn't rounded. Defaults to4.main_header_class(:string) - Defaults to"flex sticky top-0 z-20".label_header_class(:string) - Defaults to"flex-shrink-0 bg-base-100 border-r border-base-content/10 border-b-2 border-b-base-content/15 px-3 py-2 font-semibold text-sm text-base-content".column_header_class(:string) - Defaults to"text-xs text-center py-2 border-r border-base-content/5 font-medium flex-shrink-0".column_header_today_class(:string) - Defaults to"bg-primary/10 font-bold text-primary".column_divider_class(:string) - Defaults to"border-r border-base-content/5 h-full flex-shrink-0".non_working_class(:string) - Defaults to"bg-base-content/[0.04]".row_class(:string) - Defaults to"relative border-b border-base-content/5 hover:bg-base-content/[0.02]".label_col_class(:string) - Defaults to"relative flex-shrink-0 border-r border-base-content/10".label_row_class(:string) - Defaults to"flex items-center px-3 border-b border-base-content/5 overflow-hidden cursor-pointer hover:bg-base-content/[0.02]".group_header_class(:string) - Defaults to"flex items-center bg-base-200/50 border-b border-base-content/10 px-3".group_header_text_class(:string) - Defaults to"text-xs font-bold uppercase tracking-wider text-base-content/60 truncate".group_spacer_class(:string) - Defaults to"bg-base-200/50 border-b border-base-content/10 relative".bar_class(:string) - Defaults to"absolute top-1 bottom-1 rounded cursor-pointer overflow-hidden flex items-center z-10".bar_subproject_class(:string) - Defaults to"ring-1 ring-base-content/30 ring-offset-0 [background-image:repeating-linear-gradient(135deg,transparent_0_6px,rgba(0,0,0,0.08)_6px_7px)]".subproject_frame_color(:any) - Defaults to["#FEF3C7", "#DBEAFE", "#E0E7FF", "#FCE7F3"].bar_background_class(:string) - Defaults to"absolute inset-0 rounded".bar_default_color_class(:string) - Defaults to"bg-primary".bar_title_class(:string) - Defaults to"relative z-10 text-xs font-medium truncate px-2".bar_title_cancelled_class(:string) - Defaults to"line-through".progress_class(:string) - Defaults to"absolute inset-y-0 left-0 rounded-l".progress_complete_radius_class(:string) - Defaults to"rounded-r".progress_incomplete_class(:string) - Defaults to"bg-base-content/20".progress_complete_class(:string) - Defaults to"bg-success/40".milestone_class(:string) - Defaults to"absolute top-1/2 z-40 cursor-pointer w-4 h-4 border-2".milestone_default_color_class(:string) - Defaults to"bg-primary".milestone_status_cancelled_class(:string) - Defaults to"opacity-50".status_tentative_class(:string) - Defaults to"opacity-60".status_cancelled_class(:string) - Defaults to"opacity-40".status_pending_approval_class(:string) - Defaults to"animate-pulse".status_no_show_class(:string) - Defaults tonil.status_blocked_class(:string) - Defaults to"opacity-60 grayscale".bar_popover_class(:string) - Defaults to"absolute z-[60] max-w-md rounded-md shadow-lg border-2 border-base-content overflow-hidden hidden".bar_popover_title_class(:string) - Defaults to"flex items-center px-2 text-xs font-medium whitespace-normal break-words leading-tight".bar_popover_subtitle_class(:string) - Defaults to"px-2 pb-1 text-[0.65rem] opacity-80 leading-tight".bar_popover_actions_class(:string) - Defaults to"flex gap-1 px-2 py-2".bar_action_button_class(:string) - Defaults to"relative inline-flex items-center justify-center w-7 h-7 rounded hover:bg-base-content/15 cursor-pointer".bar_action_disabled_class(:string) - Defaults to"opacity-50 cursor-not-allowed pointer-events-none".label_popover_class(:string) - Defaults to"absolute left-2 right-2 z-[60] rounded-md shadow-lg border-2 border-base-content overflow-hidden hidden".badge_class(:string) - Defaults to"absolute z-50 inline-flex items-center justify-center px-1 min-w-[1rem] h-4 text-[0.55rem] font-bold rounded-full ring-1 ring-base-100 leading-none pointer-events-none".badge_default_color_class(:string) - Defaults to"bg-error".today_marker_line_class(:string) - Defaults to"absolute top-0 w-0.5 bg-error z-30 pointer-events-none".today_marker_badge_class(:string) - Defaults to"absolute top-0 -translate-x-1/2 bg-error text-error-content text-[0.55rem] px-1 rounded-b font-bold whitespace-nowrap".translations(:map) - Overrides for the chart's own CHROME strings — toolbar buttons, the Today label, prev/next, edge counts, popover/expand labels, and short month names. Shape:%{labels: %{atom => String.t()}, month_names_short: %{1..12 => String.t()}}; anything omitted falls back to the English default. SeePhoenixLiveGantt.Utils.I18nfor the full key list. This does NOT translate task content (titles, assignees, action tooltips) — you pass those already-localized in eachPhoenixLiveGantt.Task, so they work with any backend (gettext, Cldr, a JSONB multilang column, …). Defaults to%{}.class(:string) - Defaults to"".dir(:atom) - Defaults to:ltr.id(:string) - Stable DOM id. Required whenshow_headeris true ORauto_scroll_todayis on, so JS dispatches target the right gantt instance when multiple are on the page. Defaults tonil.show_header(:boolean) - Defaults tofalse.show_zoom_switcher(:boolean) - Defaults totrue.show_today_button(:boolean) - Defaults totrue.show_navigation(:boolean) - Defaults totrue.zooms(:list) - Defaults to[:day, :week, :month].on_zoom_change(:any) - Defaults tonil.on_navigate(:any) - Defaults tonil.on_scroll_today(:any) - Defaults tonil.toolbar_class(:string) - Defaults to"lg-toolbar flex items-center justify-between gap-3 px-3 py-2 border-b border-base-content/15 bg-base-100".show_edge_indicators(:boolean) - Defaults totrue.on_show_earlier(:any) - Defaults tonil.on_show_later(:any) - Defaults tonil.on_show_today(:any) - phx-click event for the off-screen Today hint (shown whentodayis outsidedate_range). Wire it to widen the range / jump to today; if nil the hint is informational only. Requiresshow_today. Defaults tonil.edge_indicator_class(:string) - Defaults to"lg-edge px-2 py-1 rounded-full bg-base-200/95 border border-base-content/10 text-[0.65rem] font-medium text-base-content/70 shadow-sm hover:bg-base-200 transition-colors".enable_hooks(:boolean) - When true, attaches BOTH JS hooks:LgAutoScrollon the container (auto-scroll + today button) andLgBarPopoveron every bar/milestone/label (the click popover + dependency-tree highlight). Requires the PhoenixLiveGantt JS bundle (priv/static/assets/phoenix_live_gantt.js, registered aswindow.PhoenixLiveGanttHooks). Leave false if you don't ship the bundle — otherwise the browser logs an "unknown hook" error per element. Defaults tofalse.auto_scroll_today(:boolean) - On mount, scroll the timeline so today is horizontally centered (if today is in range and hooks are enabled). Defaults totrue.
Slots
itemlabeltoolbar_start- Extra content rendered at the left of the toolbar, after the today/nav buttons.toolbar_end- Extra content rendered at the right of the toolbar, after the zoom switcher.
@spec scroll_to_start(Phoenix.LiveView.JS.t(), String.t()) :: Phoenix.LiveView.JS.t()
A Phoenix.LiveView.JS command that scrolls a chart's timeline back to its
start (leftmost column). Pair it with a "home"/"fit" button whose server
handler refits the window — the server can't move the scroll, and the built-in
scroll-to-today only fires when the today marker is in view, so a refit that
doesn't include today would otherwise leave the timeline scrolled to a stale
spot. Requires enable_hooks + the matching id (the LgAutoScroll hook
listens for the dispatched lg:scroll-start).
<button phx-click={
JS.push("fit_project") |> PhoenixLiveGantt.scroll_to_start("project-gantt-#{@id}")
}>Project</button>Composes with an existing JS command (e.g. a JS.push/2); pass it as the
first argument, or omit it to start a fresh command.
Toggles an id in an expanded set — convenience for on_toggle_expand
handlers so consumers don't re-write the member?/put/delete boilerplate.
Normalizes the first argument to a MapSet (accepts a MapSet, a list, or
nil) and returns a MapSet. The id should be the value delivered to your
handler under the "event-id" param key.
def handle_event("toggle_subproject", %{"event-id" => id}, socket) do
{:noreply, update(socket, :expanded, &PhoenixLiveGantt.toggle_expanded(&1, id))}
end