Advanced Features

View Source

This guide covers LiveStyle's advanced features: contextual selectors, view transitions, and CSS anchor positioning.

Contextual Selectors (LiveStyle.When)

Style elements based on ancestor, descendant, or sibling state.

Basic Usage

defmodule MyAppWeb.Card do
  use Phoenix.Component
  use LiveStyle
  alias LiveStyle.When

  class :card_content,
    transform: [
      {:default, "translateX(0)"},
      {When.ancestor(":hover"), "translateX(10px)"}
    ],
    opacity: [
      {:default, "1"},
      {When.ancestor(":focus-within"), "0.8"}
    ]

  def render(assigns) do
    ~H"""
    <div class={LiveStyle.default_marker()}>
      <div {css(:card_content)}>
        Hover the parent to move me
      </div>
    </div>
    """
  end
end

Note: When using computed keys like When.ancestor(":hover"), you must use tuple syntax {key, value} instead of keyword syntax.

Available Selectors

FunctionDescriptionUse Case
ancestor(pseudo)Style when ancestor has stateChild reacts to parent hover
descendant(pseudo)Style when descendant has stateParent reacts to child focus
sibling_before(pseudo)Style when preceding sibling has stateNext sibling reacts
sibling_after(pseudo)Style when following sibling has statePrevious sibling reacts
any_sibling(pseudo)Style when any sibling has stateAny sibling interaction

Custom Markers

Use custom markers to create independent sets of contextual selectors:

defmodule MyAppWeb.Table do
  use Phoenix.Component
  use LiveStyle
  alias LiveStyle.When

  @row_marker LiveStyle.marker(:row)
  @row_hover When.ancestor(":hover", @row_marker)

  class :cell,
    opacity: [
      {:default, "1"},
      {When.ancestor(":hover"), "0.3"},  # Dim when container hovered
      {@row_hover, "1"},                  # Restore for hovered row
      {":hover", "1"}                     # Restore for direct hover
    ],
    background_color: [
      {:default, "transparent"},
      {@row_hover, "#e0e7ff"}
    ]

  def render(assigns) do
    ~H"""
    <div class={LiveStyle.default_marker()}>
      <table>
        <tr :for={row <- @rows} class={@row_marker}>
          <td :for={cell <- row} {css(:cell)}>
            <%= cell %>
          </td>
        </tr>
      </table>
    </div>
    """
  end
end

Nested Conditions

Combine pseudo-classes with contextual selectors:

class :cell,
  background_color: [
    {:default, "transparent"},
    {":nth-child(2)", [
      {:default, nil},
      {When.ancestor(":has(td:nth-of-type(2):hover)"), "#e0e7ff"}
    ]}
  ]

View Transitions

LiveStyle provides first-class support for the View Transitions API.

Defining Transitions

defmodule MyAppWeb.Animations do
  use LiveStyle

  # Define keyframes
  keyframes :scale_in,
    from: [opacity: "0", transform: "scale(0.8)"],
    to: [opacity: "1", transform: "scale(1)"]

  keyframes :scale_out,
    from: [opacity: "1", transform: "scale(1)"],
    to: [opacity: "0", transform: "scale(0.8)"]

  keyframes :slide_from_right,
    from: [transform: "translateX(100%)"],
    to: [transform: "translateX(0)"]

  keyframes :slide_to_left,
    from: [transform: "translateX(0)"],
    to: [transform: "translateX(-100%)"]

  # Define view transitions
  view_transition_class :card,
    old: [
      animation_name: keyframes(:scale_out),
      animation_duration: "200ms",
      animation_fill_mode: "both"
    ],
    new: [
      animation_name: keyframes(:scale_in),
      animation_duration: "200ms",
      animation_fill_mode: "both"
    ]

  view_transition_class :slide,
    old: [
      animation_name: keyframes(:slide_to_left),
      animation_duration: "300ms"
    ],
    new: [
      animation_name: keyframes(:slide_from_right),
      animation_duration: "300ms"
    ]
end

defmodule MyAppWeb.ViewTransitions do
  use LiveStyle

  view_transition_class :card,
    old: [
      animation_name: keyframes({MyAppWeb.Animations, :scale_out}),
      animation_duration: "200ms",
      animation_fill_mode: "both"
    ],
    new: [
      animation_name: keyframes({MyAppWeb.Animations, :scale_in}),
      animation_duration: "200ms",
      animation_fill_mode: "both"
    ]
end

Available Pseudo-elements

KeyCSS SelectorDescription
:old::view-transition-old(name)Outgoing snapshot
:new::view-transition-new(name)Incoming snapshot
:group::view-transition-group(name)Container for old/new
:image_pair::view-transition-image-pair(name)Wrapper for snapshots
:old_only_child::view-transition-old(name):only-childElement being removed
:new_only_child::view-transition-new(name):only-childElement being added

Respecting Reduced Motion

view_transition_class :card,
  old: [
    animation_name: [
      default: keyframes(:scale_out),
      "@media (prefers-reduced-motion: reduce)": "none"
    ],
    animation_duration: "200ms"
  ],
  new: [
    animation_name: [
      default: keyframes(:scale_in),
      "@media (prefers-reduced-motion: reduce)": "none"
    ],
    animation_duration: "200ms"
  ]

Phoenix LiveView Integration

View Transitions require JavaScript integration to work with Phoenix LiveView. The key insight is that view-transition-name must be applied before startViewTransition() captures the old state snapshot.

Step 1: Create the View Transitions Adapter

Create assets/js/view-transitions.js:

/**
 * Phoenix LiveView View Transitions Adapter
 * 
 * Integrates the CSS View Transitions API with Phoenix LiveView.
 * Works with LiveView 1.1.18+ which provides the `onDocumentPatch` DOM callback.
 */

// Global state for hooks
window.__viewTransitionPending = false
window.__vtCounter = 0

export function createViewTransitionDom(options = {}) {
  const existingDom = options.dom || {}
  const animateMode = options.animate || "always"
  
  // State for explicit mode
  let transitionTypes = []
  let explicitTransitionPending = false

  // Listen for explicit transition events from LiveView
  window.addEventListener("phx:start-view-transition", (e) => {
    const opts = e.detail || {}
    if (opts.types && Array.isArray(opts.types)) {
      transitionTypes.push(...opts.types)
    }
    explicitTransitionPending = true
    window.__viewTransitionPending = true
  })

  return {
    ...existingDom,
    
    onDocumentPatch(start) {
      const existingOnDocumentPatch = existingDom.onDocumentPatch
      
      const update = () => {
        const types = transitionTypes
        transitionTypes = []
        explicitTransitionPending = false
        
        if (existingOnDocumentPatch) {
          existingOnDocumentPatch(start)
        } else {
          start()
        }
        
        window.__viewTransitionPending = false
      }

      // Check if we should animate
      const shouldAnimate = animateMode === "always" || explicitTransitionPending
      
      if (!shouldAnimate || !document.startViewTransition) {
        update()
        return
      }

      window.__viewTransitionPending = true

      // Start the view transition
      try {
        document.startViewTransition({
          update,
          types: transitionTypes.length ? transitionTypes : ["same-document"],
        })
      } catch (error) {
        // Firefox 144+ doesn't support callbackOptions yet
        document.startViewTransition(update)
      }
    },

    onBeforeElUpdated(fromEl, toEl) {
      if (existingDom.onBeforeElUpdated) {
        return existingDom.onBeforeElUpdated(fromEl, toEl)
      }
      return true
    }
  }
}

export default createViewTransitionDom

Step 2: Configure LiveSocket

In your assets/js/app.js:

import { Socket } from "phoenix"
import { LiveSocket } from "phoenix_live_view"
import { createViewTransitionDom } from "./view-transitions"

// Recommended: animate all DOM patches automatically
const liveSocket = new LiveSocket("/live", Socket, {
  params: { _csrf_token: csrfToken },
  dom: createViewTransitionDom({ animate: "always" })
})

Animation Modes:

  • animate: "always" (default) - Every LiveView DOM patch is wrapped in a view transition. Elements with view-transition-name animate automatically. This is the recommended mode as it requires no server-side coordination.

  • animate: "explicit" - Only patches preceded by a push_event("start-view-transition", ...) are animated. Use this for fine-grained control.

Step 3: Create a ViewTransition Component

Create a reusable component that manages view-transition-name via a hook:

defmodule MyAppWeb.ViewTransition do
  use Phoenix.Component

  @doc """
  Renders the hook definition. Include once in your root layout.
  """
  def hook_definition(assigns) do
    assigns = assign_new(assigns, :id, fn -> "view-transition-hook-def" end)

    ~H"""
    <div id={@id} style="display:none;">
      <script :type={Phoenix.LiveView.ColocatedHook} name=".ViewTransition">
        export default {
          mounted() {
            // Generate unique name if not provided
            if (!this.el.__vtName) {
              this.el.__vtName = this.el.dataset.viewTransitionName || `_vt_${window.__vtCounter++}_`;
            }
            // Apply immediately so transitions work on first interaction
            this.el.style.viewTransitionName = this.el.__vtName;
            if (this.el.dataset.viewTransitionClass) {
              this.el.style.viewTransitionClass = this.el.dataset.viewTransitionClass;
            }
          },

          updated() {
            // Keep name applied (morphdom may remove it)
            this.el.style.viewTransitionName = this.el.__vtName;
            if (this.el.dataset.viewTransitionClass) {
              this.el.style.viewTransitionClass = this.el.dataset.viewTransitionClass;
            }
          }
        }
      </script>
    </div>
    """
  end

  attr :id, :string, required: true
  attr :"view-transition-name", :string, default: nil
  attr :"view-transition-class", :string, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  @doc """
  Renders a view transition wrapper.
  
  Apply styles directly to the wrapper - don't use `display: contents`
  as it breaks view transition snapshots.
  """
  def view_transition(assigns) do
    ~H"""
    <div
      id={@id}
      phx-hook=".ViewTransition"
      data-view-transition-name={assigns[:"view-transition-name"]}
      data-view-transition-class={assigns[:"view-transition-class"]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </div>
    """
  end
end

Step 4: Use in Your LiveView

First, include the hook definition once in your root layout:

<!-- In root.html.heex -->
<MyAppWeb.ViewTransition.hook_definition />

Then use the component in your LiveViews:

defmodule MyAppWeb.TodoLive do
  use MyAppWeb, :live_view
  use LiveStyle
  import MyAppWeb.ViewTransition

  # Define your item styles
  class :todo_item,
    padding: "1rem",
    border_bottom: "1px solid #eee"

  # Define your transition styles
  view_transition_class :todo_item,
    group: [
      animation_duration: ".3s",
      animation_timing_function: "ease-out"
    ]

  def render(assigns) do
    ~H"""
    <ul>
      <.view_transition
        :for={todo <- @todos}
        id={"todo-#{todo.id}"}
        {css(:todo_item)}
        view-transition-class={view_transition_class(:todo_item)}
      >
        <%= todo.text %>
      </.view_transition>
    </ul>
    """
  end
end

Step 5: Write Your Event Handlers

With animate: "always" mode, your event handlers are simple - no push_event needed:

def handle_event("shuffle", _params, socket) do
  {:noreply, assign(socket, items: Enum.shuffle(socket.assigns.items))}
end

def handle_event("add_item", %{"text" => text}, socket) do
  new_item = %{id: System.unique_integer(), text: text}
  {:noreply, assign(socket, items: socket.assigns.items ++ [new_item])}
end

def handle_event("delete_item", %{"id" => id}, socket) do
  items = Enum.reject(socket.assigns.items, &(&1.id == id))
  {:noreply, assign(socket, items: items)}
end

Every DOM patch automatically triggers a view transition. Elements with view-transition-name will animate smoothly.

Explicit Mode (Optional)

If you prefer fine-grained control, use animate: "explicit" and push events:

def handle_event("shuffle", _params, socket) do
  {:noreply,
   socket
   |> assign(items: Enum.shuffle(socket.assigns.items))
   |> push_event("start-view-transition", %{types: ["shuffle"]})}
end

Key Insights

  1. Use animate: "always": This is the simplest approach - every DOM patch animates automatically. No server-side coordination needed.

  2. Apply names on mount: The view-transition-name must be set before startViewTransition() captures the old state. The hook applies it immediately in mounted().

  3. Don't use display: contents: It removes the element from the box tree and breaks view transition snapshots. Apply styles directly to the wrapper.

  4. Use :only-child for enter/exit: When elements are added or removed, use ::view-transition-new(name):only-child for enter animations and ::view-transition-old(name):only-child for exit animations.

  5. Avoid animations when unchanged: If you define custom old/new animations, they play even when elements don't change. Use :group for duration/easing on elements that move, and reserve old/new for actual enter/exit animations.

Browser Support

View Transitions are supported in Chrome 111+, Edge 111+, Safari 18+, and Firefox 144+. They gracefully degrade in unsupported browsers.

Scroll-Driven Animations

LiveStyle supports Scroll-Driven Animations - CSS animations that progress based on scroll position rather than time.

Scroll Progress Timeline

Animate based on scroll position of the document or a scrollable container:

defmodule MyAppWeb.ScrollProgress do
  use LiveStyle

  # Keyframes for the progress bar
  keyframes :grow_progress,
    from: [transform: "scaleX(0)"],
    to: [transform: "scaleX(1)"]

  # Reading progress bar at top of page
  class :progress_bar,
    position: "fixed",
    top: "0",
    left: "0",
    width: "100%",
    height: "4px",
    background: "linear-gradient(90deg, #4f46e5, #7c3aed)",
    transform_origin: "left",
    # Scroll-driven animation
    animation_name: keyframes(:grow_progress),
    animation_timeline: "scroll()",
    animation_timing_function: "linear"
end

The scroll() function creates an anonymous scroll progress timeline that tracks the nearest scrollable ancestor (or the document).

View Progress Timeline

Animate based on an element's visibility within the viewport:

defmodule MyAppWeb.RevealOnScroll do
  use LiveStyle

  keyframes :reveal,
    from: [opacity: "0", transform: "translateY(50px)"],
    to: [opacity: "1", transform: "translateY(0)"]

  class :reveal_card,
    animation_name: keyframes(:reveal),
    animation_timeline: "view()",
    animation_range: "entry 0% cover 40%",
    animation_fill_mode: "both"
end

The view() function tracks when the element enters and exits the viewport.

Named View Timelines for Parallax

For parallax effects where a child animates based on its parent's visibility, use named view timelines:

defmodule MyAppWeb.Parallax do
  use LiveStyle

  # Parallax animation - shifts background position as container scrolls
  keyframes :parallax_shift,
    from: [background_position: "center 100%"],
    to: [background_position: "center 0%"]

  # Container defines the named view timeline
  class :parallax_container,
    position: "relative",
    height: "400px",
    overflow: "hidden",
    # Define a named view timeline on the container
    view_timeline_name: "--parallax-container",
    view_timeline_axis: "block"

  # Child references the named timeline
  class :parallax_bg,
    position: "absolute",
    inset: "0",
    # Gradient taller than container for parallax movement
    background: "linear-gradient(135deg, #667eea 0%, #764ba2 50%, #667eea 100%)",
    background_size: "100% 200%",
    animation_name: keyframes(:parallax_shift),
    # Reference the container's timeline (not view())
    animation_timeline: "--parallax-container",
    animation_fill_mode: "both",
    animation_duration: "1ms"
end

Why use named timelines for parallax?

The view() function tracks when the animated element itself enters the viewport. For absolutely positioned children inside a container with overflow: hidden, the browser can't properly track the child's visibility. By defining the timeline on the container and referencing it from the child, the animation is driven by the container's visibility instead.

Horizontal Scroll Timeline

Track horizontal scroll progress with named scroll timelines:

defmodule MyAppWeb.HorizontalScroll do
  use LiveStyle

  keyframes :grow_progress,
    from: [transform: "scaleX(0)"],
    to: [transform: "scaleX(1)"]

  class :horizontal_scroll_wrapper,
    overflow_x: "auto",
    # Define a named scroll timeline for horizontal axis
    scroll_timeline_name: "--horizontal-scroll",
    scroll_timeline_axis: "x"

  class :horizontal_progress_bar,
    width: "100%",
    height: "4px",
    background: "linear-gradient(90deg, #10b981, #059669)",
    transform_origin: "left",
    # Reference the named scroll timeline
    animation_name: keyframes(:grow_progress),
    animation_timeline: "--horizontal-scroll",
    animation_timing_function: "linear",
    animation_duration: "1ms"
end

Animation Range

Control when the animation starts and ends with animation_range:

# Start at 0% of entry, end at 40% of cover
animation_range: "entry 0% cover 40%"

# Full range from entry to exit
animation_range: "entry exit"

# Start when 25% visible, end when 75% visible  
animation_range: "cover 25% cover 75%"

Range keywords:

  • entry - Element entering the viewport
  • exit - Element exiting the viewport
  • cover - Element covering the viewport
  • contain - Element contained within viewport

Browser Support

Scroll-driven animations are supported in Chrome 115+, Edge 115+, and Safari 18+. They require no JavaScript - the browser handles all animation timing based on scroll position.

CSS Anchor Positioning

LiveStyle supports CSS Anchor Positioning for advanced positioning scenarios like tooltips and popovers.

Basic Usage

defmodule MyAppWeb.Tooltip do
  use LiveStyle

  class :trigger,
    anchor_name: "--tooltip-trigger"

  class :tooltip,
    position: "absolute",
    position_anchor: "--tooltip-trigger",
    top: "anchor(bottom)",
    left: "anchor(center)",
    transform: "translateX(-50%)"
end

Position Fallbacks

Use position_try/2 for fallback positions when the preferred position doesn't fit:

defmodule MyAppWeb.Tokens do
  use LiveStyle

  position_try :flip_to_top,
    bottom: "anchor(top)",
    left: "anchor(center)"

  position_try :flip_to_left,
    right: "anchor(left)",
    top: "anchor(center)"
end

defmodule MyAppWeb.Tooltip do
  use LiveStyle

  class :tooltip,
    position: "absolute",
    position_anchor: "--trigger",
    top: "anchor(bottom)",
    left: "anchor(center)",
    position_try_fallbacks: "#{position_try({MyAppWeb.Tokens, :flip_to_top})}, #{position_try({MyAppWeb.Tokens, :flip_to_left})}"
end

Inline Position Try

For simple cases, use inline position try:

class :tooltip,
  position: "absolute",
  position_anchor: "--trigger",
  top: "anchor(bottom)",
  position_try_fallbacks: position_try(
    bottom: "anchor(top)",
    left: "anchor(center)"
  )

Allowed Properties

Only positioning-related properties are allowed in position_try:

  • Anchor: position_anchor, position_area
  • Inset: top, right, bottom, left, inset, inset_block, inset_inline
  • Margin: margin, margin_top, margin_right, etc.
  • Size: width, height, min_width, max_height, block_size, inline_size
  • Alignment: align_self, justify_self, place_self

Browser Support

CSS Anchor Positioning is available in Chromium 125+ (June 2024). Firefox and Safari don't yet support this feature. Consider feature detection or fallback positioning.

Combining Features

These features can be combined for powerful effects:

defmodule MyAppWeb.Dropdown do
  use Phoenix.Component
  use LiveStyle
  alias LiveStyle.When

  @trigger_marker LiveStyle.marker(:trigger)

  class :menu,
    position: "absolute",
    position_anchor: "--dropdown-trigger",
    top: "anchor(bottom)",
    opacity: [
      {:default, "0"},
      {When.sibling_before(":focus", @trigger_marker), "1"}
    ],
    transform: [
      {:default, "translateY(-10px)"},
      {When.sibling_before(":focus", @trigger_marker), "translateY(0)"}
    ],
    transition: "opacity 200ms, transform 200ms"

  def dropdown(assigns) do
    ~H"""
    <div>
      <button class={[@trigger_marker]} style="anchor-name: --dropdown-trigger">
        Menu
      </button>
      <div {css(:menu)}>
        <%= render_slot(@inner_block) %>
      </div>
    </div>
    """
  end
end

Next Steps