diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f06235c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+node_modules
+dist
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..b48b924
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1,2 @@
+# Disable lockfile generation
+package-lock=false
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..2fca225
--- /dev/null
+++ b/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+ Preact In Motion
+
+
+
+
+
+
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..3c9d3a2
--- /dev/null
+++ b/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "vite-preact-ts-starter",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@preact/signals": "^2",
+ "motion": "^12",
+ "preact": "^10",
+ "preact-in-motion": "*",
+ "radashi": "^12"
+ },
+ "devDependencies": {
+ "@preact/preset-vite": "^2",
+ "typescript": "^5.8.3",
+ "vite": "^7.0.4",
+ "vite-tsconfig-paths": "^5.1.4"
+ }
+}
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 0000000..37fa10a
--- /dev/null
+++ b/src/main.tsx
@@ -0,0 +1,5 @@
+import 'preact-in-motion'
+import { navigate } from './navigate'
+import './styles/main.css'
+
+navigate(new URL(location.href))
diff --git a/src/navigate.tsx b/src/navigate.tsx
new file mode 100644
index 0000000..458a88e
--- /dev/null
+++ b/src/navigate.tsx
@@ -0,0 +1,47 @@
+import { Fragment, render, type ComponentType } from 'preact'
+
+const pages = import.meta.glob(['./**/index.tsx', './404.tsx'], {
+ base: './pages/',
+})
+
+const layouts = import.meta.glob('./**/layout.tsx', {
+ base: './pages/',
+})
+
+export function navigate(url: URL) {
+ const currentPage = url.pathname
+ .replace(/^\/?/, './')
+ .replace(/\/?$/, '/index.tsx')
+
+ const getPageModule = (pages[currentPage] ?? pages['404.tsx']) as
+ | (() => Promise<{ default: ComponentType }>)
+ | undefined
+
+ let getLayoutModule: typeof getPageModule
+ for (
+ let parts = currentPage.split('/').slice(0, -1);
+ parts.length > 0;
+ parts.pop()
+ ) {
+ getLayoutModule = layouts[
+ parts.join('/') + '/layout.tsx'
+ ] as typeof getLayoutModule
+ }
+
+ getLayoutModule ??= async () => ({
+ default: Fragment,
+ })
+
+ if (getPageModule) {
+ Promise.all([getPageModule(), getLayoutModule()]).then(
+ ([{ default: Page }, { default: Layout }]) => {
+ render(
+
+
+ ,
+ document.getElementById('app')!
+ )
+ }
+ )
+ }
+}
diff --git a/src/pages/animate-presence/index.tsx b/src/pages/animate-presence/index.tsx
new file mode 100644
index 0000000..380eb7c
--- /dev/null
+++ b/src/pages/animate-presence/index.tsx
@@ -0,0 +1,3 @@
+export default () => {
+ return AnimatePresence
+}
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
new file mode 100644
index 0000000..b2d9172
--- /dev/null
+++ b/src/pages/index.tsx
@@ -0,0 +1,136 @@
+import { easeInOut, spring } from 'motion'
+import 'preact-in-motion'
+import { AnimatePresence } from 'preact-in-motion'
+import { useState } from 'preact/hooks'
+import { withExamples } from '~/utils/withExamples'
+import { withVisibility } from '~/utils/withVisibility'
+
+const BasicAnimation = ({ visible }: { visible: boolean }) => {
+ return (
+
+ Basic Animation
+
+ )
+}
+
+// Lifecycle Animations Component
+const LifecycleAnimations = () => {
+ return (
+ <>
+ Lifecycle Animations
+
+ >
+ )
+}
+
+// AnimatePresence Component
+const AnimatePresenceExample = ({ visible }: { visible: boolean }) => {
+ return (
+
+ {visible && (
+
+ Fade Out Then Unmount
+
+ )}
+
+ )
+}
+
+// AnimatePresence with Multiple Elements Component
+const AnimatePresenceMultiple = () => {
+ const [isHappy, setIsHappy] = useState(true)
+
+ return (
+
+
+ {isHappy ? (
+
+ 😄 I'm happy!
+
+ ) : (
+
+ 😢 I'm sad...
+
+ )}
+
+
+
+ )
+}
+
+// Spring Animation Component
+const SpringAnimation = () => {
+ return (
+
+ Spring Animation
+
+ )
+}
+
+// Easing Function Component
+const EasingFunction = () => {
+ return (
+
+ Easing Function Animation
+
+ )
+}
+
+export default withExamples([
+ withVisibility(BasicAnimation),
+ LifecycleAnimations,
+ withVisibility(AnimatePresenceExample),
+ AnimatePresenceMultiple,
+ SpringAnimation,
+ EasingFunction,
+])
diff --git a/src/pages/layout.tsx b/src/pages/layout.tsx
new file mode 100644
index 0000000..becb03c
--- /dev/null
+++ b/src/pages/layout.tsx
@@ -0,0 +1,17 @@
+import type { ComponentChildren } from 'preact'
+
+export default (props: { children: ComponentChildren }) => {
+ return (
+
+
+ {props.children}
+
+ )
+}
diff --git a/src/pages/options/index.tsx b/src/pages/options/index.tsx
new file mode 100644
index 0000000..ef5981b
--- /dev/null
+++ b/src/pages/options/index.tsx
@@ -0,0 +1,22 @@
+import { withExamples } from '~/utils/withExamples'
+import { withVisibility } from '~/utils/withVisibility'
+
+const TransitionOption = ({ visible }: { visible: boolean }) => {
+ return (
+
+ Transition Option
+
+ )
+}
+
+export default withExamples([
+ withVisibility(TransitionOption), //
+])
diff --git a/src/styles/base.css b/src/styles/base.css
new file mode 100644
index 0000000..833ad3f
--- /dev/null
+++ b/src/styles/base.css
@@ -0,0 +1,51 @@
+:root {
+ font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: rgba(255, 255, 255, 0.87);
+ background-color: #242424;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+body {
+ margin: 0;
+}
+
+h3 {
+ margin: 0;
+}
+
+main {
+ padding: 20px;
+}
+
+button {
+ border-radius: 8px;
+ border: 1px solid transparent;
+ padding: 0.6em 1.2em;
+ font-size: 1em;
+ font-weight: 500;
+ font-family: inherit;
+ background-color: #1a1a1a;
+ cursor: pointer;
+}
+button:focus,
+button:focus-visible {
+ outline: 4px auto -webkit-focus-ring-color;
+}
+
+@media (prefers-color-scheme: light) {
+ :root {
+ color: #213547;
+ background-color: #ffffff;
+ }
+ button {
+ background-color: #f9f9f9;
+ }
+}
diff --git a/src/styles/main.css b/src/styles/main.css
new file mode 100644
index 0000000..d484f6f
--- /dev/null
+++ b/src/styles/main.css
@@ -0,0 +1,37 @@
+@import './base.css';
+
+.examples {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ justify-content: start;
+ gap: 16px;
+}
+
+.example {
+ justify-content: center;
+ min-height: 100px;
+ border-radius: 12px;
+ background-color: #2a3a45;
+ padding: 8px 40px;
+}
+
+@media (prefers-color-scheme: light) {
+ .example {
+ background-color: #e6f0f5;
+ }
+}
+
+.column {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 16px;
+}
+
+.row {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 16px;
+}
diff --git a/src/utils/withExamples.tsx b/src/utils/withExamples.tsx
new file mode 100644
index 0000000..362ce3e
--- /dev/null
+++ b/src/utils/withExamples.tsx
@@ -0,0 +1,27 @@
+import type { ComponentType } from 'preact'
+import { isArray } from 'radashi'
+
+export function withExamples(
+ components: ComponentType[] | Record
+) {
+ return () => (
+
+ {isArray(components)
+ ? components.map(Component => (
+
+
+
+ ))
+ : Object.entries(components).map(([title, components]) => (
+ <>
+
{title}
+ {components.map(Component => (
+
+
+
+ ))}
+ >
+ ))}
+
+ )
+}
diff --git a/src/utils/withVisibility.tsx b/src/utils/withVisibility.tsx
new file mode 100644
index 0000000..8d7fe7b
--- /dev/null
+++ b/src/utils/withVisibility.tsx
@@ -0,0 +1,18 @@
+import { useSignal } from '@preact/signals'
+import type { FunctionalComponent } from 'preact'
+
+export const withVisibility =
+ (Component: FunctionalComponent<{ visible: boolean }>) => () => {
+ const visible = useSignal(true)
+ return (
+
+
+
+
+ )
+ }
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/tsconfig.app.json b/tsconfig.app.json
new file mode 100644
index 0000000..113e13c
--- /dev/null
+++ b/tsconfig.app.json
@@ -0,0 +1,29 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "skipLibCheck": true,
+ "paths": {
+ "~/*": ["./src/*"]
+ },
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "jsxImportSource": "preact",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["src"]
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/tsconfig.node.json b/tsconfig.node.json
new file mode 100644
index 0000000..f85a399
--- /dev/null
+++ b/tsconfig.node.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2023",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..f1b5672
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,8 @@
+import preact from '@preact/preset-vite'
+import { defineConfig } from 'vite'
+import tsconfigPaths from 'vite-tsconfig-paths'
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [preact(), tsconfigPaths()],
+})