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
| Specifier | Stand-in |
|---|---|
@rbxts/react | The 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-roblox | createRoot, createBlockingRoot, createLegacyRoot, createPortal, act, version — and the namespace as a default export, matching upstream’s export = typings. |
@rbxts/services | Loom’s service singletons — the same objects game.GetService(...) returns. |
@rbxts/vide | @loom-dev/vide, the signals adapter on the same Scene IR. |
@rbxts/ui-labs | The non-story Environment — root specifier only. |
@rbxts/ripple @rbxts/react-ripple | A port of the published spring/tween/motion implementation. |
The preview plugin injects the roblox-ts Promise (evaera’s) at module scope in previewed source. It is injected rather than installed as a global on purpose. The page’s own Promise belongs to the host, and the two disagree about all and allSettled. Overwriting the global would break the bundler, React and loom itself.
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()returnsfalse.Environment.InputListenerisundefined, since it is story-only.Environment.UserInputis loom’sUserInputService, the same objectgame.GetService(...)returns, never a copy.Unmount,Reload,CreateSnapshotandSetStoryHolderare no-ops, andGetJanitor()returnsundefined, 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;Loom is not the Studio plugin and a loom scene is not a story. Story creators, controls, snapshots, sandbox injection, story mounting and the plugin APIs are not emulated. Importing one fails with the normal ESM missing-export error, which beats a stub that behaves differently than in Studio. Subpaths are not covered either.
@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:
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 point | Where shims goes |
|---|---|
loomPreview() | The plugin options object. |
loom preview / loom build | loom.config.ts. |
createGalleryServer / buildGallery | The loom-dev/embed options object. |
withLoomGallery() | The loom-dev/next options object (paths relative to root). |
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.
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.
A gallery target is a lazy import(), so the dev server never fetches a scene you do not open. loom build follows every target eagerly to code-split it. A gallery that runs fine in development can still fail the build. A package reached only from a scene you never opened waits until then to tell you.