# LiveStyle
Atomic CSS-in-Elixir for Phoenix LiveView, inspired by [Meta's StyleX](https://stylexjs.com/).
LiveStyle provides a type-safe, composable styling system with:
- **Atomic CSS**: Each property-value pair becomes a single class
- **Deterministic hashing**: Same styles always produce same class names
- **CSS Variables**: Type-safe design tokens with `defvars/2`
- **Theming**: Override variables with `create_theme/3`
- **@layer support**: CSS cascade layers for predictable specificity
- **Last-wins merging**: Like StyleX, later styles override earlier ones
## Installation
Add `live_style` to your dependencies in `mix.exs`:
```elixir
def deps do
[
{:live_style, "~> 0.1.0"}
]
end
```
Add the LiveStyle compiler to your project:
```elixir
def project do
[
# ...
compilers: Mix.compilers() ++ [:live_style]
]
end
```
Include the generated CSS in your root layout:
```heex
<link rel="stylesheet" href={~p"/assets/live.css"} />
```
## Configuration
Configure LiveStyle in your `config/config.exs`:
```elixir
config :live_style,
output_path: "priv/static/assets/live.css",
manifest_path: "_build/live_style_manifest.etf"
```
### Options
- `:output_path` - Path where the generated CSS file is written (default: `"priv/static/assets/live.css"`)
- `:manifest_path` - Path where the compile-time manifest is stored (default: `"_build/live_style_manifest.etf"`). Useful for monorepos or custom build directories.
## Quick Start
```elixir
defmodule MyAppWeb.Components.Button do
use Phoenix.Component
use LiveStyle
# Define styles using the macro
style :base, %{
display: "flex",
align_items: "center",
padding: "8px 16px",
border_radius: "8px"
}
style :primary, %{
background_color: var(:fill_primary),
color: "white"
}
def button(assigns) do
~H"""
<button class={style([:base, :primary])}>
{render_slot(@inner_block)}
</button>
"""
end
end
```
## Design Tokens
Define CSS custom properties for your design system:
```elixir
defmodule MyApp.Tokens do
use LiveStyle.Tokens
defvars :color, %{
white: "#ffffff",
black: "#000000",
primary: "#1e68fa"
}
defvars :fill, %{
primary: "#3b82f6",
secondary: "#6b7280"
}
defvars :space, %{
sm: "8px",
md: "16px",
lg: "24px"
}
end
```
Use tokens in your styles with the `var/1` macro:
```elixir
style :container, %{
padding: var(:space_md),
background_color: var(:fill_primary)
}
```
## Theming
Create theme overrides that scope to an element and its children:
```elixir
defmodule MyApp.Tokens do
use LiveStyle.Tokens
defvars :fill, %{
background: "#ffffff",
surface: "#f5f5f5"
}
create_theme :dark, :fill, %{
background: "#1a1a1a",
surface: "#2d2d2d"
}
end
```
Apply theme in templates:
```heex
<div class={MyApp.Tokens.dark()}>
<!-- Children use dark theme colors -->
<.button>I use dark colors</.button>
</div>
```
## Conditional Styles
Use Elixir's boolean logic for conditional styles:
```elixir
def button(assigns) do
~H"""
<button class={style([:base, @variant == :primary && :primary, @disabled && :disabled])}>
{render_slot(@inner_block)}
</button>
"""
end
```
## Pseudo-classes and Media Queries
LiveStyle uses the StyleX pattern of condition-in-value:
```elixir
style :link, %{
color: %{
default: "blue",
":hover": "darkblue",
":focus": "navy"
},
text_decoration: "none"
}
style :container, %{
padding: %{
default: "16px",
"@media (min-width: 768px)": "32px"
}
}
```
## Pseudo-elements
```elixir
style :with_before, %{
position: "relative",
"::before": %{
content: "'*'",
color: "red",
position: "absolute",
left: "-1em"
}
}
```
## Style Composition
Include styles from other modules or self-reference within the same module:
```elixir
# External module include
defmodule MyApp.BaseStyles do
use LiveStyle
style :button_base, %{
display: "inline-flex",
padding: "8px 16px",
cursor: "pointer"
}
end
defmodule MyApp.Button do
use LiveStyle
style :primary, %{
__include__: [{MyApp.BaseStyles, :button_base}],
background_color: var(:fill_primary)
}
end
# Self-reference (same module)
defmodule MyApp.Card do
use LiveStyle
style :base, %{
border_radius: "8px",
padding: "16px"
}
style :elevated, %{
__include__: [:base],
box_shadow: "0 4px 6px rgba(0,0,0,0.1)"
}
end
```
## Keyframes Animations
```elixir
defmodule MyApp.Animations do
use LiveStyle
keyframes :spin, %{
from: %{transform: "rotate(0deg)"},
to: %{transform: "rotate(360deg)"}
}
style :spinner, %{
animation_name: :spin,
animation_duration: "1s",
animation_iteration_count: "infinite"
}
end
```
## Fallback Values
Use `first_that_works/1` for CSS fallbacks:
```elixir
style :sticky_header, %{
position: first_that_works(["sticky", "-webkit-sticky", "fixed"])
}
```
## Contextual Selectors (LiveStyle.When)
Style elements based on ancestor, descendant, or sibling state - like StyleX's `stylex.when.*` API:
```elixir
defmodule MyApp.Card do
use LiveStyle
import LiveStyle.When
style :card_content, %{
transform: %{
default: "translateX(0)",
ancestor(":hover"): "translateX(10px)"
}
}
def render(assigns) do
~H"""
<div class={LiveStyle.default_marker()}>
<div class={style(:card_content)}>
Hover the parent to move me
</div>
</div>
"""
end
end
```
### Available Selectors
| Function | Description | Generated CSS Pattern |
|----------|-------------|----------------------|
| `ancestor(pseudo)` | Style when ancestor has state | `.class:where(.marker:hover *)` |
| `descendant(pseudo)` | Style when descendant has state | `.class:where(:has(.marker:focus))` |
| `sibling_before(pseudo)` | Style when preceding sibling has state | `.class:where(.marker:hover ~ *)` |
| `sibling_after(pseudo)` | Style when following sibling has state | `.class:where(:has(~ .marker:focus))` |
| `any_sibling(pseudo)` | Style when any sibling has state | Combined selector |
### Custom Markers
Use custom markers to create independent sets of contextual selectors:
```elixir
defmodule MyApp.Table do
use LiveStyle
import LiveStyle.When
@row_marker LiveStyle.define_marker(:row)
@row_hover ancestor(":hover", @row_marker)
@col_hover ancestor(":has(td:nth-of-type(2):hover)")
style :cell, %{
opacity: conditions([
{:default, "1"},
{ancestor(":hover"), "0.1"}, # Dim when container hovered
{@row_hover, "1"}, # Restore for hovered row
{":hover", "1"} # Restore for direct hover
]),
background_color: conditions([
{:default, "transparent"},
{@row_hover, "#2266cc77"},
{@col_hover, "#2266cc77"},
{":hover", "#2266cc77"}
])
}
def render(assigns) do
~H"""
<div class={LiveStyle.default_marker()}>
<table>
<tr class={@row_marker}>
<td class={style(:cell)}>Cell</td>
</tr>
</table>
</div>
"""
end
end
```
### The `conditions/1` Helper
Use `conditions/1` when you need module attributes as condition keys:
```elixir
@row_hover ancestor(":hover", @row_marker)
style :cell, %{
opacity: conditions([
{:default, "1"},
{@row_hover, "0.5"}, # Module attribute as key
{":hover", "1"}
])
}
```
### Nested Conditions
Combine pseudo-classes with contextual selectors for precise targeting:
```elixir
style :cell, %{
background_color: conditions([
{:default, "transparent"},
# Only apply to nth-child(2) when column 2 is hovered
{":nth-child(2)", %{
:default => nil,
ancestor(":has(td:nth-of-type(2):hover)") => "#2266cc77"
}}
])
}
```
## Typed Variables
For advanced use cases like animating gradients:
```elixir
defmodule MyApp.Tokens do
use LiveStyle.Tokens
import LiveStyle.Types
defvars :anim, %{
rotation: angle("0deg"),
progress: percentage("0%")
}
defvars :theme, %{
accent: color("#ff0000")
}
end
```
This generates CSS `@property` rules that enable CSS to interpolate values.
## CSS Generation
CSS is automatically generated when you compile with the LiveStyle compiler.
You can also generate manually:
```bash
mix live_style.gen.css
```
Or specify a custom output path:
```bash
mix live_style.gen.css -o assets/css/live_style.css
```
## Development Watcher
For development, add the watcher to your Phoenix endpoint:
```elixir
# config/dev.exs
config :my_app, MyAppWeb.Endpoint,
watchers: [
live_style: {LiveStyle.Watcher, :watch, [[]]}
]
```
The watcher monitors the LiveStyle manifest file and regenerates CSS whenever styles are recompiled. This requires the `file_system` dependency (included with `phoenix_live_reload`).
## Generated CSS Structure
```css
@property --v1234567 {
syntax: '<color>';
inherits: true;
initial-value: #ff0000;
}
:root {
--v1234567: #ff0000;
--v2345678: 16px;
}
@keyframes k1234567 {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@layer live_style {
.x1a2b3c4 { display: flex; }
.x2b3c4d5 { padding: var(--v2345678); }
.x3c4d5e6 { background-color: var(--v1234567); }
}
```
## API Reference
### Core Macros (via `use LiveStyle`)
| Macro | Description |
|-------|-------------|
| `style/2` | Define a named style with CSS declarations |
| `keyframes/2` | Define a keyframes animation |
| `var/1` | Reference a CSS custom property |
| `first_that_works/1` | Declare fallback values for a property |
| `conditions/1` | Build conditional value map from tuples (for module attrs as keys) |
### Marker Functions
| Function | Description |
|----------|-------------|
| `LiveStyle.default_marker/0` | Returns the default marker class (`"x-marker"`) |
| `LiveStyle.define_marker/1` | Creates a unique marker class for custom contexts |
### Contextual Selectors (via `import LiveStyle.When`)
| Function | Description |
|----------|-------------|
| `ancestor/1,2` | Style when ancestor has pseudo-state |
| `descendant/1,2` | Style when descendant has pseudo-state |
| `sibling_before/1,2` | Style when preceding sibling has pseudo-state |
| `sibling_after/1,2` | Style when following sibling has pseudo-state |
| `any_sibling/1,2` | Style when any sibling has pseudo-state |
### Token Macros (via `use LiveStyle.Tokens`)
| Macro | Description |
|-------|-------------|
| `defvars/2` | Define CSS custom properties with a namespace |
| `defconsts/2` | Define compile-time constants (not CSS variables) |
| `defkeyframes/2` | Define keyframes in token modules |
| `create_theme/3` | Create theme overrides for a var group |
### Type Helpers (via `import LiveStyle.Types`)
| Function | Description |
|----------|-------------|
| `color/1` | CSS `<color>` type |
| `length/1` | CSS `<length>` type |
| `angle/1` | CSS `<angle>` type |
| `integer/1` | CSS `<integer>` type |
| `number/1` | CSS `<number>` type |
| `time/1` | CSS `<time>` type |
| `percentage/1` | CSS `<percentage>` type |
### Module Functions
| Function | Description |
|----------|-------------|
| `LiveStyle.get_all_css/0` | Get complete CSS output |
| `LiveStyle.clear/0` | Clear all collected CSS (useful for testing) |
| `LiveStyle.output_path/0` | Get configured CSS output path |
| `LiveStyle.manifest_path/0` | Get configured manifest path |
## Why LiveStyle?
### vs Tailwind CSS
- **Type-safe tokens**: Design tokens are Elixir values, not magic strings
- **No purging complexity**: Only styles you use are generated
- **Elixir-native**: Conditional logic uses `&&` and `||`, not string concatenation
- **Scoped theming**: Override tokens for subtrees without global CSS
### vs Inline Styles
- **Pseudo-classes**: `:hover`, `:focus`, etc. work naturally
- **Media queries**: Responsive design without JavaScript
- **Performance**: Atomic classes are cached and deduplicated
- **DevTools**: Inspect class names instead of inline style blobs
### Inspired by StyleX
LiveStyle brings Meta's StyleX philosophy to Phoenix LiveView:
- Atomic CSS for minimal bundle size
- Last-wins merging for predictable composition
- Deterministic class names for caching
- CSS variables for theming
## License
MIT License - see [LICENSE](LICENSE) for details.