Loomguides

Package compatibility

The automatic source redirect, the built-in adapters, and the `shims` option for everything else.

Loom runs a roblox-ts source tree in a browser, so every package that tree imports needs something a browser can execute. Most have one and you never think about it. This page is about the few that do not.

The automatic case

A normal roblox-ts package points "main" at compiled Luau:

{ "main": "out/init.luau", "types": "out/index.d.ts" }

Loom’s resolver sees the .luau main and redirects the specifier to that package’s own TypeScript source at src/index.ts instead. It runs before Vite resolves anything, so a missing out/ is not a problem. That covers most of the ecosystem with no configuration. @rbxts/* specifiers are excluded deliberately — those go through the alias table.

The case that needs help

A declaration-only package has nothing to redirect to — a Luau runtime plus .d.ts files, and no TypeScript implementation:

{ "main": "src/init.lua", "types": "src/index.d.ts" }

Declaration files are types, not code, and the Luau cannot run in a browser. The only answer is a browser module standing in for the package. Loom ships one where the meaning is unambiguous, and lets you write one for anything else.

What loom ships

SpecifierStand-in
@rbxts/reactThe full browser-meaningful runtime surface of @rbxts/react@17.3.7-ts.2, forwarding standard React by identity to loom’s own React 18.
@rbxts/react-robloxcreateRoot, createBlockingRoot, createLegacyRoot, createPortal, act, version — and the namespace as a default export, matching upstream’s export = typings.
@rbxts/servicesLoom’s service singletons — the same objects game.GetService(...) returns.
@rbxts/vide@loom-dev/vide, the signals adapter on the same Scene IR.
@rbxts/ui-labsThe non-story Environment — root specifier only.
@rbxts/ripple @rbxts/react-rippleA port of the published spring/tween/motion implementation.

Everything below is the detail on the last two.

@rbxts/ui-labs

The root import works with no configuration and no local compatibility file:

import { Environment } from "@rbxts/ui-labs";

Loom models the non-story environment, exactly as UI Labs behaves when the same code runs outside a story:

  • Environment.IsStory() returns false.
  • Environment.InputListener is undefined, since it is story-only.
  • Environment.UserInput is loom’s UserInputService, the same object game.GetService(...) returns, never a copy.
  • Unmount, Reload, CreateSnapshot and SetStoryHolder are no-ops, and GetJanitor() returns undefined, so ordinary cleanup code does not fail.

So the usual reusable-input guard picks loom’s own service with nothing to configure:

import { Environment } from "@rbxts/ui-labs";
import { UserInputService } from "@rbxts/services";
export const InputService = Environment.IsStory()
? Environment.InputListener
: UserInputService;

@rbxts/ripple and @rbxts/react-ripple

Both root imports work with no configuration:

import { config, useSpring } from "@rbxts/react-ripple";
function AnimatedButton() {
const [offset, spring] = useSpring(0, config.stiff);
return (
<textbutton
Size={offset.map((value) => UDim2.fromOffset(200 + value, 50 + value))}
Event={{
MouseEnter: () => spring.setGoal(10),
MouseLeave: () => spring.setGoal(0),
}}
/>
);
}

Ripple publishes a Luau runtime and a .d.ts, so loom answers both packages with a port rather than a stub. The spring integrator, easing curves, Oklab colour interpolation and rest thresholds all follow the Luau source, so a component animates as it does in Roblox. Full API surface in Animation.

shims

For any other package loom cannot run, point the specifier at a browser module you write:

vite.config.ts
loomPreview({
shims: { "@rbxts/example": "./loom-shims/example.ts" },
});

Targets are absolute paths, paths relative to the project root, or bare package ids. Matching is exact, so @rbxts/example does not capture @rbxts/example/controls — list a subpath separately when you mean to cover it. Shims are matched ahead of loom’s own aliases, so they override the built-in compatibility above too.

The option exists on every entry path, since they share one Vite config:

Entry pointWhere shims goes
loomPreview()The plugin options object.
loom preview / loom buildloom.config.ts.
createGalleryServer / buildGalleryThe loom-dev/embed options object.
withLoomGallery()The loom-dev/next options object (paths relative to root).
loom.config.ts
export default {
targets: "src/scenes",
shims: { "@rbxts/example": "./loom-shims/example.ts" },
};

Writing a shim

Model what the package means here, and only the slice your code uses. Import loom’s own modules the way app code does: @rbxts/services is aliased to loom’s singletons, so a shim re-exporting one hands back the same object game.GetService(...) returns.

loom-shims/example.ts
import { UserInputService } from "@rbxts/services";
export const listener = UserInputService;

Never construct a second service. Leaving the rest out is the point. An import of something you did not shim fails loudly, rather than behaving differently than it does in Studio.

The error you are trying to avoid

A package loom cannot run and you have not shimmed says so directly, naming the importer and the fix:

[loom] Package "@rbxts/example" only provides a Lua/Luau runtime
("src/init.lua") and cannot run in the browser.
Imported by /project/src/app.ts
Provide a browser-compatible replacement with:
loomPreview({
shims: {
"@rbxts/example": "./loom-shims/example.ts",
},
});

That message replaces what you would otherwise get. Vite’s opaque Failed to resolve entry for package, or, only during a build, Rollup trying to parse the Luau as JavaScript:

RollupError:
../node_modules/@rbxts/example/src/init.luau (1:6):
Expected ';', '}' or <eof>

Both mean the same thing: the package’s npm runtime is .lua/.luau, so loom needs a browser adapter for it. Nothing can be inferred — Luau is not JavaScript, and a .d.ts is types rather than code.