Advanced Features
View SourceThis 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
endNote: When using computed keys like
When.ancestor(":hover"), you must use tuple syntax{key, value}instead of keyword syntax.
Available Selectors
| Function | Description | Use Case |
|---|---|---|
ancestor(pseudo) | Style when ancestor has state | Child reacts to parent hover |
descendant(pseudo) | Style when descendant has state | Parent reacts to child focus |
sibling_before(pseudo) | Style when preceding sibling has state | Next sibling reacts |
sibling_after(pseudo) | Style when following sibling has state | Previous sibling reacts |
any_sibling(pseudo) | Style when any sibling has state | Any 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
endNested 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"
]
endAvailable Pseudo-elements
| Key | CSS Selector | Description |
|---|---|---|
: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-child | Element being removed |
:new_only_child | ::view-transition-new(name):only-child | Element 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 createViewTransitionDomStep 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 withview-transition-nameanimate automatically. This is the recommended mode as it requires no server-side coordination.animate: "explicit"- Only patches preceded by apush_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
endStep 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
endStep 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)}
endEvery 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"]})}
endKey Insights
Use
animate: "always": This is the simplest approach - every DOM patch animates automatically. No server-side coordination needed.Apply names on mount: The
view-transition-namemust be set beforestartViewTransition()captures the old state. The hook applies it immediately inmounted().Don't use
display: contents: It removes the element from the box tree and breaks view transition snapshots. Apply styles directly to the wrapper.Use
:only-childfor enter/exit: When elements are added or removed, use::view-transition-new(name):only-childfor enter animations and::view-transition-old(name):only-childfor exit animations.Avoid animations when unchanged: If you define custom
old/newanimations, they play even when elements don't change. Use:groupfor duration/easing on elements that move, and reserveold/newfor 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"
endThe 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"
endThe 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"
endWhy 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"
endAnimation 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 viewportexit- Element exiting the viewportcover- Element covering the viewportcontain- 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%)"
endPosition 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})}"
endInline 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
endNext Steps
- Configuration - Shorthand behaviors and CSS layers