diff --git a/packages/runtime-tests/integration/composables/use-box-metrics.test.tsx b/packages/runtime-tests/integration/composables/use-box-metrics.test.tsx
new file mode 100644
index 0000000..d2b800c
--- /dev/null
+++ b/packages/runtime-tests/integration/composables/use-box-metrics.test.tsx
@@ -0,0 +1,117 @@
+import { defineComponent, nextTick, ref, shallowRef, watchEffect } from "vue";
+import { describe, expect, test } from "vite-plus/test";
+import { render } from "@vue-tui/testing";
+import { Box, Text, useBoxMetrics, measureElement } from "@vue-tui/runtime";
+
+describe("useBoxMetrics", () => {
+ test("returns layout dimensions after render", async () => {
+ const dims = shallowRef({ w: 0, h: 0 });
+ const App = defineComponent(() => {
+ const boxRef = ref(null);
+ const metrics = useBoxMetrics(boxRef);
+ watchEffect(() => {
+ dims.value = { w: metrics.width.value, h: metrics.height.value };
+ });
+ return () => (
+
+ test
+
+ );
+ });
+ await render(App);
+ // useBoxMetrics defers measurement to nextTick after the commit
+ await nextTick();
+ expect(dims.value.w).toBe(20);
+ expect(dims.value.h).toBe(5);
+ });
+
+ test("returns left and top positions", async () => {
+ const pos = shallowRef({ l: -1, t: -1 });
+ const App = defineComponent(() => {
+ const boxRef = ref(null);
+ const metrics = useBoxMetrics(boxRef);
+ watchEffect(() => {
+ pos.value = { l: metrics.left.value, t: metrics.top.value };
+ });
+ return () => (
+
+
+ inner
+
+
+ );
+ });
+ await render(App);
+ await nextTick();
+ expect(pos.value.l).toBe(0);
+ expect(pos.value.t).toBe(0);
+ });
+
+ test("hasMeasured starts false", async () => {
+ let measured = false;
+ const App = defineComponent(() => {
+ const boxRef = ref(null);
+ const metrics = useBoxMetrics(boxRef);
+ // During setup, hasMeasured is false (layout not computed yet)
+ measured = metrics.hasMeasured.value;
+ return () => (
+
+ x
+
+ );
+ });
+ await render(App);
+ // During setup, hasMeasured was false
+ expect(measured).toBe(false);
+ });
+
+ test("hasMeasured becomes true after layout", async () => {
+ const hasMeasuredRef = shallowRef(false);
+ const App = defineComponent(() => {
+ const boxRef = ref(null);
+ const metrics = useBoxMetrics(boxRef);
+ watchEffect(() => {
+ hasMeasuredRef.value = metrics.hasMeasured.value;
+ });
+ return () => (
+
+ x
+
+ );
+ });
+ await render(App);
+ await nextTick();
+ expect(hasMeasuredRef.value).toBe(true);
+ });
+});
+
+describe("measureElement", () => {
+ test("returns { width: 0, height: 0 } for null", () => {
+ expect(measureElement(null)).toEqual({ width: 0, height: 0 });
+ });
+
+ test("returns { width: 0, height: 0 } for undefined", () => {
+ expect(measureElement(undefined)).toEqual({ width: 0, height: 0 });
+ });
+
+ test("returns { width: 0, height: 0 } for object without yoga", () => {
+ expect(measureElement({})).toEqual({ width: 0, height: 0 });
+ });
+
+ test("returns dimensions from a node with yoga property", () => {
+ const fakeYoga = {
+ getComputedWidth: () => 42,
+ getComputedHeight: () => 17,
+ };
+ expect(measureElement({ yoga: fakeYoga })).toEqual({ width: 42, height: 17 });
+ });
+
+ test("resolves through $el for component instances", () => {
+ const fakeYoga = {
+ getComputedWidth: () => 30,
+ getComputedHeight: () => 10,
+ };
+ const fakeInstance = { $el: { yoga: fakeYoga } };
+ expect(measureElement(fakeInstance)).toEqual({ width: 30, height: 10 });
+ });
+});
diff --git a/packages/runtime/src/composables/useBoxMetrics.ts b/packages/runtime/src/composables/useBoxMetrics.ts
new file mode 100644
index 0000000..035680c
--- /dev/null
+++ b/packages/runtime/src/composables/useBoxMetrics.ts
@@ -0,0 +1,117 @@
+import { nextTick, shallowRef, watchPostEffect, type Ref, type ShallowRef } from "vue";
+import type { Node as YogaNode } from "yoga-layout";
+
+// Yoga's `right`/`bottom` are omitted: always `0` for flow layout and
+// unintuitive for absolute positioning. Matches Ink's BoxMetrics type.
+
+export interface BoxMetrics {
+ /** Element width. */
+ readonly width: number;
+ /** Element height. */
+ readonly height: number;
+ /** Distance from the left edge of the parent. */
+ readonly left: number;
+ /** Distance from the top edge of the parent. */
+ readonly top: number;
+}
+
+export interface UseBoxMetricsResult {
+ /** Reactive element width. */
+ readonly width: ShallowRef;
+ /** Reactive element height. */
+ readonly height: ShallowRef;
+ /** Reactive distance from the left edge of the parent. */
+ readonly left: ShallowRef;
+ /** Reactive distance from the top edge of the parent. */
+ readonly top: ShallowRef;
+ /** Whether the currently tracked element has been measured in the latest layout pass. */
+ readonly hasMeasured: ShallowRef;
+}
+
+/**
+ * Resolve a ref value to the underlying TUI node with a yoga property.
+ * Handles both direct TUI node refs and Vue component instance refs (where
+ * the TUI node is accessible via `$el`).
+ */
+function resolveYogaNode(value: unknown): { yoga: YogaNode } | null {
+ if (!value) return null;
+ const obj = value as Record;
+ // Direct TUI node (e.g. from host element ref)
+ if (obj.yoga) return obj as { yoga: YogaNode };
+ // Vue component instance — root host element is on $el
+ if (obj.$el && (obj.$el as Record).yoga) {
+ return obj.$el as { yoga: YogaNode };
+ }
+ return null;
+}
+
+/**
+ * Imperative function that reads yoga computed dimensions from a TUI node.
+ *
+ * Returns `{ width: 0, height: 0 }` before layout (when yoga node doesn't
+ * exist or hasn't been calculated).
+ *
+ * Note: `measureElement()` returns `{width: 0, height: 0}` when called during
+ * render (before layout is calculated). Call it from post-render code, such as
+ * `watchPostEffect`, `onMounted`, input handlers, or timer callbacks.
+ */
+export function measureElement(node: unknown): { width: number; height: number } {
+ const tuiNode = resolveYogaNode(node);
+ if (!tuiNode) return { width: 0, height: 0 };
+ return {
+ width: tuiNode.yoga.getComputedWidth() ?? 0,
+ height: tuiNode.yoga.getComputedHeight() ?? 0,
+ };
+}
+
+/**
+ * Reactive composable that returns computed layout metrics for a tracked box element.
+ * Updates after each render commit when yoga layout has been calculated.
+ *
+ * Returns `{ width, height, left, top, hasMeasured }` where all values are
+ * reactive refs. `hasMeasured` starts `false` and becomes `true` after the
+ * first layout pass.
+ *
+ * @example
+ * ```tsx
+ * const boxRef = ref(null);
+ * const { width, height, left, top, hasMeasured } = useBoxMetrics(boxRef);
+ * return () => (
+ *
+ * {hasMeasured.value ? `${width.value}x${height.value}` : "Measuring..."}
+ *
+ * );
+ * ```
+ */
+export function useBoxMetrics(ref: Ref): UseBoxMetricsResult {
+ const width = shallowRef(0);
+ const height = shallowRef(0);
+ const left = shallowRef(0);
+ const top = shallowRef(0);
+ const hasMeasured = shallowRef(false);
+
+ function measure() {
+ const node = resolveYogaNode(ref.value);
+ if (!node) return;
+ width.value = node.yoga.getComputedWidth();
+ height.value = node.yoga.getComputedHeight();
+ left.value = node.yoga.getComputedLeft();
+ top.value = node.yoga.getComputedTop();
+ hasMeasured.value = true;
+ }
+
+ // Re-measure after each render commit. watchPostEffect triggers when the
+ // ref changes (component mount / unmount). The yoga layout is calculated
+ // inside the commit scheduler's queuePostFlushCb, which may run after this
+ // watcher in the same flush cycle. We use nextTick to defer the read so
+ // that it runs after the scheduler's commit has called calculateLayout.
+ watchPostEffect(() => {
+ // Access ref.value to track the dependency — when the ref changes,
+ // this effect re-runs and schedules a new measurement.
+ const node = resolveYogaNode(ref.value);
+ if (!node) return;
+ void nextTick(measure);
+ });
+
+ return { width, height, left, top, hasMeasured };
+}
diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts
index 2797000..84c17b3 100644
--- a/packages/runtime/src/index.ts
+++ b/packages/runtime/src/index.ts
@@ -17,4 +17,10 @@ export { useStdin } from "./composables/useStdin.ts";
export { useStdout } from "./composables/useStdout.ts";
export { useStderr } from "./composables/useStderr.ts";
export { useTerminalSize } from "./composables/useTerminalSize.ts";
+export {
+ useBoxMetrics,
+ measureElement,
+ type BoxMetrics,
+ type UseBoxMetricsResult,
+} from "./composables/useBoxMetrics.ts";
export type { DevState, DevErrorInfo } from "./hmr.ts";