Luna は、MoonBit と JavaScript を使用して Web アプリケーションのユーザーインターフェースを構築するための宣言型 UI ライブラリです。Luna はクラウドおよびエッジコンピューティング向けに設計された言語である MoonBit で書かれているのが特徴です。その他の JavaScript フレームワークと比較してバンドルサイズが小さく抑えるように設計されています。また Web Components First を謳っており、ネイティブなブラウザ標準を重視しています。
Luna のエコシステムは以下の 3 つの主要なパッケージで構成されています。
| パッケージ名 | 説明 | 言語 |
|---|---|---|
| Sol SSG | 静的サイトジェネレーター | Markdown + Islands |
| Sol | フルスタック Web フレームワーク | MoonBit |
| Luna UI | 宣言型 UI ライブラリ | MoonBit or JavaScript/TypeScript |
この記事では、Luna UI と MoonBit を使用してシンプルな Web アプリケーションを作成する方法を紹介します。
Luna UI プロジェクトを作成する
まず、Luna UI プロジェクトを作成します。以下のコマンドを実行して、プロジェクトの雛形を生成します。--mbt オプションを指定することで、MoonBit を使用したプロジェクトが作成されます。
npx @luna_ui/luna new myapp --mbt次に、作成したプロジェクトのディレクトリに移動して依存関係をインストールします。2026 年 2 月時点では mizchi/signals パッケージを追加でインストールする必要がありました。
cd myapp
moon update
moon add mizchi/signals
npm installプロジェクトの構造は以下のようになります。
.
├── _build
├── index.html
├── main.ts
├── moon.mod.json
├── package.json
├── src
│ ├── lib.mbt
│ └── moon.pkg.json
├── target -> _build
├── tsconfig.json
└── vite.config.ts簡単にプロジェクトの各ファイルの役割について見ていきましょう。moon.mod.json は MoonBit プロジェクトのモジュールを定義するファイルです。パッケージのメタデータや依存関係が含まれています。 moon add コマンドを使用して deps フィールドに追加します。プロジェクトを作成した段階では mizchi/luna と mizchi/js が含まれています。
{
"name": "internal/myapp",
"version": "0.0.1",
"deps": {
"mizchi/luna": "0.1.3",
"mizchi/js": "0.10.6",
"mizchi/signals": "0.6.1"
},
"source": "src",
"preferred-target": "js"
}エントリーポイントは src/lib.mbt です。ここではコードの内容に深くは触れませんが、Luna UI を使用してシンプルなカウンターアプリケーションが実装されています。
// Luna Counter App
///|
fn main {
let count = @signals.signal(0)
let doubled = @signals.memo(fn() { count.get() * 2 })
let doc = @js_dom.document()
match doc.getElementById("app") {
Some(el) => {
let app = @dom.div([
@dom.h1([@dom.text("Luna Counter (MoonBit)")]),
@dom.p([@dom.text_dyn(fn() { "Count: " + count.get().to_string() })]),
@dom.p([@dom.text_dyn(fn() { "Doubled: " + doubled().to_string() })]),
@dom.div(class="buttons", [
@dom.button(
on=@dom.events().click(_ => count.update(fn(n) { n + 1 })),
[@dom.text("+1")],
),
@dom.button(
on=@dom.events().click(_ => count.update(fn(n) { n - 1 })),
[@dom.text("-1")],
),
@dom.button(on=@dom.events().click(_ => count.set(0)), [
@dom.text("Reset"),
]),
]),
])
@dom.render(el |> @dom.DomElement::from_dom, app)
}
None => ()
}
}src/moon.pkg.json を配置することでこのディレクトリが MoonBit パッケージとして認識されます。
{
"is-main": true,
"supported-targets": ["js"],
"import": [
"mizchi/signals",
{
"path": "mizchi/luna/dom",
"alias": "dom"
},
{
"path": "mizchi/js/browser/dom",
"alias": "js_dom"
}
],
"link": {
"js": {
"format": "esm"
}
}
}is-main フィールドはこのパッケージが実行可能であることを示します。fn main 関数がエントリーポイントとして使用されます。import フィールドでは、Luna UI と MoonBit の標準ライブラリから必要なモジュールをインポートしています。alias として設定した名前が @dom や @js_dom としてコード内で使用されます。link フィールドでは、JavaScript ターゲット向けの出力形式を指定しています。
moon build コマンドを実行すると、_build ディレクトリにビルド成果物が生成されます。ビルド成果物は vite-plugin-moonbit を使用して Vite プロジェクトに統合されます。vite.config.ts ファイルでは、Vite の設定が行われています。watch: true オプションを指定することで .mbt ファイルの変更を監視し、自動的に再ビルドされます。
import { defineConfig } from "vite";
import { moonbit } from "vite-plugin-moonbit";
export default defineConfig({
plugins: [
moonbit({
watch: true,
showLogs: true,
}),
],
});main.ts ファイルは、ビルドされた MoonBit コードをインポートしています。
// Import MoonBit module via mbt: prefix
import "mbt:internal/myapp";mbt:internal/myapp のパスは tsconfig.json の paths フィールドで定義されています。ビルド成果物のパスを指定していることがわかりますね。
{
"compilerOptions": {
"target": "ES2023",
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
"allowJs": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"paths": {
"mbt:internal/myapp": [
"./target/js/release/build/app/app.js"
]
}
},
"include": [
"*.ts"
]
}main.ts は index.html から参照されており、ブラウザでアプリケーションを起動するためのエントリーポイントとなります。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>myapp</title>
</head>
<body>
<h1>myapp</h1>
<div id="app"></div>
<script type="module" src="/main.ts"></script>
</body>
</html>npm run dev コマンドを実行して開発サーバーを起動し、ブラウザで http://localhost:3000 にアクセスすると、Luna UI を使用したカウンターアプリケーションが表示されます。このように MoonBit をビルドして JavaScript コードを生成 → main.ts でインポート → index.html で参照、という流れで Luna UI アプリケーションを構築できます。

カウンターアプリケーションの内部の仕組み
カウンターアプリケーションがどのように動作しているのか、コードを順に追っていきましょう。はじめに @js_dom.document() を使用してブラウザのドキュメントオブジェクトを取得しています。@js_dom は mizchi/js/browser/dom モジュールのエイリアスで、ブラウザの DOM 操作をするための関数が提供されています。@js_dom.document() は Web API の window.document に対応しています。
doc.getElementById("app") を使用して、HTML 内の id="app" の要素を取得しています。結果は Option 型で返され、要素が存在する場合は Some(el)、存在しない場合は None となります。match 式を使用して結果を分岐しています。id="app" の要素が存在する場合、Luna UI のコンポーネントを作成してレンダリングします。
///|
fn main {
let doc = @js_dom.document()
match doc.getElementById("app") {
Some(el) => {
// コンポーネントの作成とレンダリング...
}
None => ()
}
}コンポーネントの作成には @dom 名前空間の関数を使用しています。例えば、@dom.div は <div> 要素を作成し、@dom.h1 は <h1> 要素を作成します。これらの関数は子要素の配列を引数として受け取ります。現在は関数 DSL スタイルで要素をネストして HTML 構造を表現していますが、jsx サポートのプロポーザルも進行中であり、将来は React のような記法も利用できるようになる予定です。
以下のコードは <div><h1>Hello, Luna UI!</h1></div> を作成する例です。
let app = @dom.div([@dom.h1([@dom.text("Hello, Luna UI!")])])アプリケーションをレンダリングするには、@dom.render 関数を使用します。最初の引数にコンポーネントをレンダリングするコンテナ要素を、2 番目の引数にレンダリングする DOM Node を指定します。親要素は el |> @dom.DomElement::from_dom を使用して @js_dom.Element 型を @dom.DomElement 型に変換しています。
:::info
|> 演算子はパイプライン演算子で、左辺の値を右辺の関数の最初の引数として渡します。el |> @dom.DomElement::from_dom は @dom.DomElement::from_dom(el) と同等です。パイプライン演算子はデータの流れを直感的に表現できたり、関数適用のネストを減らせる利点があります。
:::
///|
fn main {
let doc = @js_dom.document()
match doc.getElementById("app") {
Some(el) => {
let app = @dom.div([@dom.h1([@dom.text("Hello, Luna UI!")])])
@dom.render(el |> @dom.DomElement::from_dom, app)
}
None => ()
}
}ここまでのコードで「Hello, Luna UI!」と表示されるだけの静的なコンポーネントが作成できました。

コンポーネントは別の関数に分割して定義できます。以下の例では counter 関数を定義し、その中でコンポーネントを作成しています。main 関数は単に counter() を呼び出して結果をレンダリングするだけになりました。今後はこの counter 関数内で状態管理やイベント処理を追加していきます。
///|
fn counter() -> @dom.DomNode {
@dom.div([@dom.h1([@dom.text("Hello, Luna UI!")])])
}
///|
fn main {
let doc = @js_dom.document()
match doc.getElementById("app") {
Some(el) => {
let app = counter()
@dom.render(el |> @dom.DomElement::from_dom, app)
}
None => ()
}
}Signals を使用したリアクティブな状態管理
状態管理のコードを見ていきましょう。Luna UI では Signals ライブラリを使用してリアクティブな状態管理をします。Signal は alien-signals や Solid.js の影響を受けており、依存関係を自動で追跡し、Signal の値が変更されたときそれに依存するすべての計算と effect が再実行されます。
まず、@signals.signal(0) を使用して count という Signal を作成しています。初期値は 0 です。Signal はリアクティブな状態を表現するための基本的な単位です。
let count = @signals.signal(0)Signal の現在の値を取得するには count.get() を使用します。以下のコードでは Count: 0 というテキストを表示しています。
///|
fn counter() -> @dom.DomNode {
let count = @signals.signal(0)
@dom.div([@dom.h1([@dom.text("Count: " + count.get().to_string())])])
}
Signal の値を更新するには count.set(newValue) または count.update(fn(oldValue) { ... }) を使用します。set は新しい値を直接設定し、update は現在の値を引数として受け取り新しい値を計算する関数を渡します。
let count = @signals.signal(0)
count.set(5) // count の値を 5 に設定
count.update(fn(n) { n + 1 }) // count の値をインクリメントボタンをクリックしたときに count の値を更新するには、@dom.button の on プロパティにクリックイベントハンドラ @dom.events().click(...) を設定します。以下のコードでは、+1 ボタンがクリックされたときに count の値をインクリメント、-1 ボタンがクリックされたときにデクリメントしています。
///|
fn counter() -> @dom.DomNode {
let count = @signals.signal(0)
@dom.div([
@dom.h1([@dom.text("Count: " + count.get().to_string())]),
@dom.div(class="buttons", [
@dom.button(on=@dom.events().click(_ => count.update(fn(n) { n + 1 })), [
@dom.text("+1"),
]),
@dom.button(on=@dom.events().click(_ => count.update(fn(n) { n - 1 })), [
@dom.text("-1"),
]),
]),
])
}しかし、このコードには問題があります。count の値が更新されても、表示されるテキストは自動的に更新されません。これは、Luna UI のコンポーネントが初回レンダリング時にのみ評価され、その後の Signal の変更を追跡しないためです。
Signal の値に基づいて動的にテキストを更新するには、@dom.text_dyn(fn() { ... }) を使用します。text_dyn は関数を引数として受け取り、その関数が返す値をテキストノードとして表示します。テキストノードは再レンダリングされるたびに遅延評価されるため、Signal の値が変更されると変更が反映されます。
///|
fn counter() -> @dom.DomNode {
let count = @signals.signal(0)
@dom.div([
@dom.h1([@dom.text_dyn(fn() { "Count: " + count.get().to_string() })]),
@dom.div(class="buttons", [
@dom.button(on=@dom.events().click(_ => count.update(fn(n) { n + 1 })), [
@dom.text("+1"),
]),
@dom.button(on=@dom.events().click(_ => count.update(fn(n) { n - 1 })), [
@dom.text("-1"),
]),
]),
])
}これで、ボタンをクリックして count の値を更新すると、表示されるテキストも自動的に更新されるようになりました。
effect で Signal の変更を監視する
@signals.effect() はコールバック関数を受け取り、その中で参照される Signal の変更を監視します。Signal の値が変更されると、effect 内の関数が再実行されます。これにより、Signal の変更に応じて副作用を発生させることができます。Signal の追跡は自動的に行われるため、明示的に依存関係を指定する必要はありません。
以下のコードでは、count の値が変更されるたびにコンソールに現在の値をログ出力しています。
///|
fn counter() -> @dom.DomNode {
let count = @signals.signal(0)
let dispose = @signals.effect(fn() {
@js_console.log(@js_core.any(count.get().to_string()))
})
@dom.div([
@dom.h1([@dom.text_dyn(fn() { "Count: " + count.get().to_string() })]),
@dom.div(class="buttons", [
@dom.button(on=@dom.events().click(_ => count.update(fn(n) { n + 1 })), [
@dom.text("+1"),
]),
@dom.button(on=@dom.events().click(_ => count.update(fn(n) { n - 1 })), [
@dom.text("-1"),
]),
]),
])
}console.log() を呼び出すために mizchi/js/web/console と mizchi/js/core モジュールをインポートする必要があります。@js_console.log() は mizchi/js/core/Any 型の引数を受け取るため、@js_core.any() を使用して String 型を Any 型に変換しています。
{
"is-main": true,
"supported-targets": ["js"],
"import": [
"mizchi/signals",
{
"path": "mizchi/luna/dom",
"alias": "dom"
},
{
"path": "mizchi/js/browser/dom",
"alias": "js_dom"
},
{
"path": "mizchi/js/web/console",
"alias": "js_console"
},
{
"path": "mizchi/js/core",
"alias": "js_core"
}
],
"link": {
"js": {
"format": "esm"
}
}
}ボタンをクリックするたびに count の値が更新され、コンソールに現在の値が出力されるようになりました。
Luna CSS で CSS を生成する
mizchi/luna/x/css モジュールを使用すると、以下の 2 つのアプローチで CSS を生成できます。
- MoonBit Runtime: SSR 時にクラス名を生成する
- 静的抽出: ビルド時に
.mbtファイルから CSS ファイルを生成する
どちらの方法でも DJB2 ハッシュアルゴリズムを使用して一貫したクラス名が生成されるため、スタイルの競合を防止できます。
mizchi/luna/x/css モジュールをインポートし、@css を使用できるようにしましょう。src/moon.pkg.json ファイルの import フィールドに追加します。
{
"is-main": true,
"supported-targets": ["js"],
"import": [
"mizchi/signals",
{
"path": "mizchi/luna/dom",
"alias": "dom"
},
{
"path": "mizchi/js/browser/dom",
"alias": "js_dom"
},
{
"path": "mizchi/js/web/console",
"alias": "js_console"
},
{
"path": "mizchi/js/core",
"alias": "js_core"
},
{
"path": "mizchi/luna/x/css",
"alias": "css"
}
],
"link": {
"js": {
"format": "esm"
}
}
}CSS スタイルを定義するために @css.styles() を使用します。CSS のプロパティと値をタプルとして指定し、配列で複数のスタイルをまとめます。以下のコードでは、.button クラスに対して背景色、文字色、パディング、ボーダー、カーソルスタイルを定義しています。@css.hover のように擬似クラスもサポートされています。
作成したクラスは @dom.button の class プロパティに設定して使用します。
///|
fn counter() -> @dom.DomNode {
let count = @signals.signal(0)
let dispose = @signals.effect(fn() {
@js_console.log(@js_core.any(count.get().to_string()))
})
let button_style = @css.styles([
("background-color", "blue"),
("color", "white"),
("padding", "8px 16px"),
("border", "none"),
("cursor", "pointer"),
])
let button_hover = @css.hover("background-color", "darkblue")
@dom.div([
@dom.h1([@dom.text_dyn(fn() { "Count: " + count.get().to_string() })]),
@dom.div(class="buttons", [
@dom.button(
on=@dom.events().click(_ => count.update(fn(n) { n + 1 })),
class=button_style + " " + button_hover,
[@dom.text("+1")],
),
@dom.button(
on=@dom.events().click(_ => count.update(fn(n) { n - 1 })),
class=button_style + " " + button_hover,
[@dom.text("-1")],
),
]),
])
}この時点ではまだ CSS は生成されていません。まずは @luna_ui/luna npm パッケージをインストールし、vite.config.ts ファイルで Luna CSS プラグインを有効化します。
npm install @luna_ui/luna -Dimport { defineConfig } from "vite";
import { moonbit } from "vite-plugin-moonbit";
import { lunaCss } from "@luna_ui/luna/vite-plugin";
export default defineConfig({
plugins: [
moonbit({
watch: true,
showLogs: true,
}),
lunaCss({
src: ["src"],
verbose: true,
}),
],
});続いて main.ts ファイルでビルドされた Luna CSS のスタイルシートをインポートします。
// Import MoonBit module via mbt: prefix
import "mbt:internal/myapp";
import "virtual:luna.css";最後に luna css extract コマンドを実行して、src ディレクトリ内の .mbt ファイルから CSS ファイルを生成します。
npx luna css extract src -o _build/styles.css以下のような CSS ファイルが生成されます。
._4753n {
background-color: blue;
}
._2l2qn {
color: white;
}
._5wakl {
padding: 8px 16px;
}
._62ajx {
border: none;
}
._6p3a6 {
cursor: pointer;
}
._2s4fh:hover {
background-color: darkblue;
}確かにボタンにスタイルが適用されていることがわかりますね。

コンポーネントライブラリ
Luna UI には ARIA Authoring Practices Guide に基づいたアクセシブルなコンポーネントライブラリが用意されています。
スピンボタンコンポーネントを使用するために "mizchi/luna/x/components/styled/spinbutton" と mizchi/luna/dom/client モジュールをインポートしましょう。src/moon.pkg.json ファイルの import フィールドに追加します。
{
"is-main": true,
"supported-targets": ["js"],
"import": [
"mizchi/signals",
{
"path": "mizchi/luna/dom",
"alias": "dom"
},
{
"path": "mizchi/js/browser/dom",
"alias": "js_dom"
},
{
"path": "mizchi/js/web/console",
"alias": "js_console"
},
{
"path": "mizchi/js/core",
"alias": "js_core"
},
{
"path": "mizchi/luna/x/css",
"alias": "css"
},
{
"path": "mizchi/luna/dom/client",
"alias": "dom_client"
},
"mizchi/luna/x/components/styled/spinbutton"
],
"link": {
"js": {
"format": "esm"
}
}
}既存の Button 要素を spinbutton コンポーネントに置き換えます。spinbutton コンポーネントは Spinbutton Pattern に基づいており、現在の値を表示するテキストフィールド、増加ボタン、減少ボタンの 3 つのコンポーネントで構成されます。
@spinbutton.spinbutton() 関数は @mizchi/luna/core.Node[@js_core.Any, String] 型を返すので、@dom_client.render_vnode_to_dom で @js_dom.Node 型に変換してからさらに @dom.dom_node() で @dom.DomNode 型に変換しています。
///|
fn counter() -> @dom.DomNode {
let count = @signals.signal(0.0)
let dispose = @signals.effect(fn() {
@js_console.log(@js_core.any(count.get().to_string()))
})
@dom.div([
@dom.h1([@dom.text_dyn(fn() { "Count: " + count.get().to_string() })]),
@spinbutton.spinbutton(count, min=0.0, max=10.0, step=1.0, label="Counter")
|> @dom_client.render_vnode_to_dom
|> @dom.dom_node,
])
}これでスピンボタンコンポーネントが表示され、増加・減少ボタンをクリックすると count の値が更新されるようになりました。BEM 命名規則に基づいたクラス名がコンポーネントの各要素に割り当てられているため、このクラスを使用してスタイルをカスタマイズできます。.spinbutton や .spinbutton__button などのクラス名が割り当てられています。

まとめ
- Luna UI は MoonBit または JavaScript/TypeScript 向けの宣言型 UI ライブラリ
- Signals を使用してリアクティブな状態管理を実現
@dom.divや@dom.buttonなどの関数を使用してコンポーネントを作成@signals.signal()で Signal を作成し、get()で値を取得、set()やupdate()で値を更新- Signal の変更に応じて動的にテキストを更新するには
@dom.text_dyn(fn() { ... })を使用 @signals.effect()で Signal の変更を監視し、副作用を発生mizchi/luna/x/cssモジュールで CSS を生成し、スタイルを適用- Luna UI のコンポーネントライブラリを使用してアクセシブルな UI コンポーネントを構築
