Early access

Native apps with Lynx

Build with familiar Octane components, then render them with native views on iOS and Android. The examples below use that same code and run here in the browser with Lynx for Web.

How Lynx and Octane work together

Lynx is an open-source UI renderer from ByteDance and is used in TikTok. It runs application code across multiple threads and renders with native views on iOS, Android and HarmonyOS. Lynx for Web can run the same bundle in a browser.

Octane provides the programming model. @octanejs/lynx connects Octane to Lynx, so you can keep using components, hooks, context, Suspense and transitions. The compiler targets Lynx’s element API instead of the DOM, with no virtual DOM in between.

This example brings Lynx’s Product Gallery tutorial to Octane. It combines a two-column image gallery with a like button on every card and a custom scrollbar. Each like button keeps its own state, and Lynx reuses off-screen list items instead of rendering every card at once.

The gallery scrolls automatically and reverses direction when it reaches the top or bottom. The custom scrollbar follows that movement on the main thread, keeping it in sync frame by frame. Open the Device tab to run the same example on a phone.

Loading the example…

The component looks much like an Octane component for the web. @for renders the cards, @if changes the heart icon when a card is liked, and a regular function keeps the scrollbar moving on the main thread:

tsrx
export function Gallery({ pictureData }: { pictureData: Picture[] }) @{
	const scrollbarMTSRef = useMainThreadRef<object>();
	const galleryMTSRef = useMainThreadRef<object>();
	const galleryRef = useRef<LynxPublicHandle | null>(null);

	const onScrollMTS = (event: { detail: GalleryScrollDetail }) => {
		'main thread';
		const scrollbar = scrollbarMTSRef.current;
		const gallery = galleryMTSRef.current;
		if (!scrollbar || !gallery) return;

		const listHeight = SystemInfo.pixelHeight / SystemInfo.pixelRatio - 48;
		const scrollbarTop = listHeight * (event.detail.scrollTop / event.detail.scrollHeight);
		__AddInlineStyle(scrollbar, 'top', scrollbarTop + 'px');
		__FlushElementTree();

		const atBottom = event.detail.scrollTop + listHeight >= event.detail.scrollHeight;
		if (event.detail.deltaY > 0 && atBottom) {
			__InvokeUIMethod(
				gallery,
				'autoScroll',
				{ rate: '-60', start: true, autoStop: true },
				() => {},
			);
		} else if (event.detail.deltaY < 0 && event.detail.scrollTop <= 0) {
			__InvokeUIMethod(
				gallery,
				'autoScroll',
				{ rate: '60', start: true, autoStop: true },
				() => {},
			);
		}
	};

	useEffect(() => {
		void galleryRef.current?.invoke('autoScroll', {
			rate: '60',
			start: true,
			autoStop: true,
		});
	}, []);

	<list
		ref={galleryRef}
		main-thread:ref={galleryMTSRef}
		list-type="waterfall"
		span-count={2}
		main-thread:bindscroll={onScrollMTS}
	>
		@for (const picture of pictureData; key picture.id) {
			<list-item item-key={picture.id}>
				<LikeImageCard picture={picture} />
			</list-item>
		}
	</list>
}

Product carousel animated on the main thread

This example brings Lynx’s Product Detail tutorial to Octane. Drag tracking and the release and snap animations run on the main thread, so they do not have to wait for the background thread. The background thread keeps track of the current image and updates the indicator below the carousel.

The browser preview moves through all eight images automatically, then changes direction at each end. The panel opens preview.json, where that sequence is defined.

Loading the example…

Two threads, one component tree

Lynx uses two JavaScript environments. The main thread controls the native element tree and the work that must happen within a frame. The background thread handles state, effects, refs and most application logic. Octane compiles one component for both environments; you mark only the parts that need to run on the main thread:

tsrx
const onTouchMove = (event: MainThread.TouchEvent) => {
	'main thread';
	// Runs on the frame that produced the touch.
};

The rest of the component can use useState, useEffect, context, Suspense and <Activity> just as it does on the web. These APIs run on the background thread without changing how you write the component. Octane’s usual differences from React still apply; Lynx does not add another component syntax.

Run it yourself

@octanejs/lynx and @octanejs/rspeedy-plugin are not published yet. For now, run the examples from a clone of the repository:

bash
git clone https://github.com/octanejs/octane.git
cd octane && pnpm install
pnpm --filter @octanejs/rspeedy-plugin exec rspeedy dev --root examples/gallery --environment lynx

Install Lynx Explorer on a phone or simulator, then scan the QR code printed by the development server. Lynx Explorer loads the bundle and runs it with the native engine, so you can test the example on a device.

An Octane application for Lynx uses a standard Rspeedy configuration with the Octane plugin:

js
// lynx.config.mjs
import { defineConfig } from '@lynx-js/rspeedy';
import { pluginOctane } from '@octanejs/rspeedy-plugin';

export default defineConfig({
	environments: { lynx: {}, web: {} },
	source: { entry: { main: './src/index.ts' } },
	plugins: [pluginOctane()],
});

environments.lynx creates the native bundle for Lynx Explorer. environments.web creates the Lynx-for-Web bundle used by the previews on this page. Keeping both lets you test the same screen on a device and share it in a browser.

Current status

For current support details and known gaps, see packages/lynx/status.json. Each example README also records what has been tested on a device. If you would like to help, start with the open pull requests.