Loom runs a roblox-ts source tree in a browser, so every package that tree imports needs something a browser can execute. Most roblox-ts packages have one and you never think about it. A few do not, and this page is about those.
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 walks up from the importer, sees the .luau main, and redirects the specifier to
that package’s own TypeScript source at src/index.ts or src/index.tsx instead. It runs before
Vite resolves anything, so it works whether or not the package was ever compiled — an out/ that
does not exist is not a problem.
That covers the large majority of the ecosystem with no configuration. @rbxts/* specifiers are
deliberately excluded from the redirect: those go through the alias
table, not to their own source.
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 running the Luau in a browser is not an option. The only answer is a browser module that stands in for the package. Loom ships one for the packages whose browser 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. |
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 how UI Labs itself behaves when the same code runs outside a story:
Environment.IsStory()returnsfalse.Environment.InputListenerisundefined; 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 (CreateReactStory and
friends), controls, snapshots, sandbox injection, story mounting, hot-reload internals and the plugin
APIs are not emulated — importing one fails with the normal ESM missing-export error, which is
better than a stub that behaves differently than it does in Studio. Subpaths like
@rbxts/ui-labs/controls 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 of the
published implementation rather than a stub. The spring integrator, the easing curves, the Oklab
colour interpolation and the rest thresholds all follow the Luau source, so a component animates the
way it does in Roblox. The full API surface and the value types it animates are 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 — @rbxts/example does not capture @rbxts/example/controls, which keeps a partial shim
from silently answering for a subpath it was never written for. List the subpath separately when you
mean to cover it.
Shims are matched ahead of every one of loom’s own aliases, so they can override the built-in compatibility above as well.
The option exists on every entry path, since they all 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 actually uses. Import loom’s own
modules the way app code does — @rbxts/services is aliased to loom’s service singletons, so a shim
that re-exports one hands back the same object game.GetService(...) returns.
import { UserInputService } from "@rbxts/services";
export const listener = UserInputService;Never construct a second service. And leaving the rest out is the point: an import of something you did not shim fails with the normal ESM missing-export error, which beats a stub that behaves 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
runtime adapter for it. Nothing can be inferred automatically — 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, while
loom build (and next build) follows every target eagerly to code-split it. A gallery that runs
fine in development can still fail the build. That is why loom applies the same aliases and the same
resolver in both modes — but a package reached only from a scene you never opened will still wait
until the build to tell you.