5.9 KiB
Mixins, Styling, and Events
What This Covers
How to attach behavior, styles, and DOM-aware setup to host elements with mix. Read this when the task involves:
- DOM event handling with
on(...) - Static styling with
css(...)and dynamic styling withstyle - Imperative DOM access via
ref(...) - Navigation behavior on non-anchor elements with
link(...) - Native click, pointer, and keyboard behavior with
on(...), plus attributes withattrs(...) - Element-level animation mixins from
remix/ui/animation
For richer animation work (springs, tweens, layout transitions), see animate-elements.md. For authoring custom mixins, see create-mixins.md. For component lifecycle and updates, see component-model.md.
Compose behavior on host elements with mix. Pass a single mixin directly (mix={on(...)}), or an array when composing multiple mixins (mix={[css(...), on(...)]}). Core mixins are imported from remix/ui; animation mixins are imported from remix/ui/animation.
on(type, handler, capture?)
Attaches a typed DOM event handler. The handler receives the event and an AbortSignal that aborts when the handler is re-entered or the component is removed — this prevents race conditions:
<input
mix={on('input', async (event, signal) => {
let query = event.currentTarget.value
loading = true
handle.update()
let response = await fetch(`/search?q=${query}`, { signal })
let data = await response.json()
if (signal.aborted) return
results = data.results
loading = false
handle.update()
})}
/>
Multiple events on the same element:
<form
mix={on('submit', (event) => {
event.preventDefault()
let formData = new FormData(event.currentTarget)
})}
>
css(styles)
Applies generated class names for CSS object styles. Produces static CSS rules inserted into the document. Supports pseudo-selectors, pseudo-elements, attribute selectors, descendant selectors, and media queries using & to reference the current element:
<button
mix={css({
color: 'white',
backgroundColor: 'blue',
padding: '12px 24px',
borderRadius: '4px',
border: 'none',
cursor: 'pointer',
'&:hover': { backgroundColor: 'darkblue' },
'&:active': { transform: 'scale(0.98)' },
'&:disabled': { opacity: 0.5, cursor: 'not-allowed' },
'& .title': { fontSize: '20px', fontWeight: 'bold' },
'@media (max-width: 768px)': { width: '100%' },
})}
/>
css(...) vs style prop
Use css(...) for static styles, selectors, and media queries. Use style for dynamic values that change often. Prefer CSS nested selectors for parent-state-affects-children over managing hover/focus state in JavaScript:
<div
mix={css({
backgroundColor: 'blue', // static
'&:hover': { '& .title': { color: 'blue' } }, // parent hover → child
})}
style={{ width: `${progress}%` }} // dynamic
/>
ref(callback)
Calls a callback when an element is inserted. The callback receives the DOM node and an AbortSignal that aborts when the element is removed:
<input mix={ref((node) => node.focus())} />
<div mix={ref((node, signal) => {
let observer = new ResizeObserver((entries) => {
dimensions.width = Math.round(entries[0].contentRect.width)
handle.update()
})
observer.observe(node)
signal.addEventListener('abort', () => observer.disconnect())
})} />
The ref callback runs once when the element is first rendered, not on every update.
link(href, options?)
Adds client-side navigation behavior to any element. Makes non-anchor elements behave like Remix navigation links:
<article mix={link('/courses/intro')}>
<h3>Introduction</h3>
</article>
Options match NavigationOptions: src, target, history ('push' | 'replace'), resetScroll.
On a native anchor, the history option renders the corresponding rmx-history="push|replace" attribute so the enhanced navigation uses the same history behavior.
Native press and keyboard interactions
Use native DOM events directly with on(...). For buttons and links, click already includes keyboard activation when the element has the right semantics:
<button mix={on('click', () => doAction())}>Action</button>
For gesture-specific behavior, compose the pointer or keyboard events the interaction actually needs:
<button
mix={[
on('pointerdown', (event) => {
event.currentTarget.setPointerCapture(event.pointerId)
}),
on('pointerup', () => doAction()),
]}
>
Action
</button>
<div
tabIndex={0}
mix={on('keydown', (event) => {
if (event.key === 'Escape') close()
if (event.key === 'Enter' || event.key === ' ') doAction()
})}
/>
attrs()
Sets HTML attributes through the mixin system.
Animation Mixins
animateEntrance(config)
Animates an element when it is inserted into the DOM. Config specifies the starting style:
<div mix={animateEntrance({ opacity: 0, transform: 'translateY(8px)', duration: 180 })} />
animateExit(config)
Animates an element when it is removed. Config specifies the ending style. The element is kept in the DOM until the animation completes:
{
isVisible && (
<div
key="panel"
mix={[
animateEntrance({ opacity: 0, transform: 'scale(0.98)', ...spring('smooth') }),
animateExit({ opacity: 0, duration: 120, easing: 'ease-in' }),
]}
/>
)
}
animateLayout(config?)
Animates layout changes (position/size) using FLIP-style transforms:
{
items.map((item) => (
<li key={item.id} mix={animateLayout({ duration: 220, easing: 'ease-out' })} />
))
}
Options: duration (default 200ms), easing (default spring snappy), size (boolean, default true — include scale projection for size changes).
Always key elements you expect to animate. Use ...spring(preset) to spread duration and easing into any animation config.