Documentation

Motion Components is a library of physics-based web components. Compose primitives to build natural, interruptible interactions.

Installation

npm install motion-components

Import once. All custom elements register themselves as a side effect:

import 'motion-components'

Import a specific class if you need the TypeScript type or want to call methods:

import { MotionReveal } from 'motion-components'

const el = document.querySelector('motion-reveal') as MotionReveal
el.replay()

No bundler? Use the CDN directly:

<script type="module">
  import 'https://cdn.jsdelivr.net/npm/motion-components@1/+esm'
</script>

Use only what you need

Every component ships as its own subpath export. Import a single tag and the bundler will only pull in that file. The rest of the library never reaches your bundle, so you pay only for what you use.

Just one component:

// registers <motion-countdown> only
import 'motion-components/motion-countdown'

A handful of components:

import 'motion-components/motion-hover'
import 'motion-components/motion-reveal'
import 'motion-components/motion-stagger'

Import the class for typing or method access:

import { MotionCountdown } from 'motion-components/motion-countdown'

const el = document.querySelector<MotionCountdown>('motion-countdown')!
console.log(el.to)

CDN, single component:

<script type="module">
  import 'https://cdn.jsdelivr.net/npm/motion-components@1/dist/motion-countdown.js/+esm'
</script>

The full bundle (import 'motion-components') is convenient and brotli-compresses to ~40 KB for all components, Lit and Motion included. Per-component imports drop straight to the cost of just the tags you use.

Frameworks

Most frameworks work without configuration. A few need a small one-time setup so their compiler doesn't treat unknown tags as errors.

Import preload CSS and the library. Works without configuration.

import { preloadCSS } from 'motion-components/preload'
import 'motion-components'

const style = document.createElement('style')
style.textContent = preloadCSS
document.head.appendChild(style)

React 19 has full custom element support. Add preload and import in your entry file.

import { preloadCSS } from 'motion-components/preload'
import 'motion-components'

const style = document.createElement('style')
style.textContent = preloadCSS
document.head.appendChild(style)

export function App() {
  return (
    <motion-hover scale="1.08">
      <button>Hover me</button>
    </motion-hover>
  )
}

Configure custom elements in Vite and add preload in your entry file.

import { preloadCSS } from 'motion-components/preload'
import 'motion-components'

const style = document.createElement('style')
style.textContent = preloadCSS
document.head.appendChild(style)

// vite.config.ts — tell Vue to accept motion-* tags
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [
    vue({
      template: {
        compilerOptions: {
          isCustomElement: tag => tag.startsWith('motion-')
        }
      }
    })
  ]
})

Add CUSTOM_ELEMENTS_SCHEMA and the preload stylesheet to angular.json.

// angular.json — add preload.css to styles
"styles": [
  "node_modules/motion-components/dist/preload.css"
]

// app.component.ts — register schema
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'

@Component({
  selector: 'app-root',
  standalone: true,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `<motion-hover><button>Hover</button></motion-hover>`
})
export class AppComponent {}

Flash prevention

Web components are undefined when the HTML first renders. The browser treats <motion-flip-card> as a plain HTMLElement. Its Light DOM children (slot="back" content, dialog panels, etc.) display as unstyled visible DOM before the JavaScript bundle arrives and calls customElements.define(). Slot-based components are especially sensitive because they carry visible Light DOM.

Solution: inline CSS in <head>

The only 100% reliable fix: inject a <style> tag in the document <head> that hides every component while it's :not(:defined). The CSS must be available synchronously before the first paint. A render-blocking CSS file (<link>) arrives after the HTML, leaving a timing gap. Inline CSS has zero gap, zero flash risk.

import { preloadCSS } from 'motion-components/preload'

const style = document.createElement('style')
style.textContent = preloadCSS
document.head.appendChild(style)

Framework examples

Each example below inlines preloadCSS as a <style> tag in the document <head>. The import uses the motion-components/preload subpath. This file has zero side effects and evaluates cleanly in Node.js during SSR.

Inline CSS in main.tsx. Works before first paint.

import { preloadCSS } from 'motion-components/preload'
import 'motion-components'

// Inject into <head> (framework-agnostic):
const style = document.createElement('style')
style.textContent = preloadCSS
document.head.appendChild(style)

Inline in app/layout.tsx (App Router) or pages/_app.tsx.

import { preloadCSS } from 'motion-components/preload'
import 'motion-components'

export default function RootLayout({ children }) {
  return (
    <html>
      <head>
        <style>{preloadCSS}</style>
      </head>
      <body>{children}</body>
    </html>
  )
}

Import in frontmatter, inline with is:inline.

---
import { preloadCSS } from 'motion-components/preload'
---
<html>
  <head>
    <style is:inline set:html={preloadCSS}></style>
  </head>
  <body><slot /></body>
</html>

Use useHead in app.vue or your root layout.

<script setup>
import { preloadCSS } from 'motion-components/preload'
useHead({ style: [{ children: preloadCSS }] })
</script>

Inline in +layout.svelte via <svelte:head>.

<script>
  import { preloadCSS } from 'motion-components/preload'
  import 'motion-components'
</script>

<svelte:head>
  <style>{preloadCSS}</style>
</svelte:head>

Add preload.css to the styles array.

"styles": [
  "node_modules/motion-components/dist/preload.css"
]

Alternative: CSS import

Simpler to set up, slightly less reliable: import the preload.css file. Most bundlers convert this to a render-blocking <link>. The browser won't paint until the CSS arrives. The gap between receiving the HTML and fetching the CSS is small but non-zero. Suitable for development and less flash-sensitive setups.

import 'motion-components/preload.css'

Or link it from a CDN in <head>:

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/motion-components/dist/preload.css">