solid-sfc
Single-file components for SolidJS 2.0.
A .solid file holds one component. It has three parts:
- A
<script>block with the component code. It supports compile-time functions such as$signal. <style>blocks. They are scoped to the component.- Markup. It is JSX with a shorter attribute syntax.
<script>
interface Props {
initial: number;
}
const props = $props<Props>();
let count = $signal(props.initial);
</script>
<style>
button {
color: ${count > 10 ? 'red' : 'black'};
}
</style>
<button onClick=(() => count++)>Count: {count}</button>
Documentation
The sections below are a summary.
Install
pnpm add solid-js @solidjs/web solid-sfc
pnpm add -D vite @solidjs/vite-plugin
Setup
Add the Vite plugin. It includes @solidjs/vite-plugin, so do not add that plugin again.
// vite.config.ts
import { defineConfig } from 'vite';
import solidSFC from 'solid-sfc/vite';
export default defineConfig({
plugins: [solidSFC()],
});
Add the types to tsconfig.json:
{
"compilerOptions": {
"types": ["solid-sfc/client"]
}
}
Options
solidis passed to@solidjs/vite-plugin.rootis the project root, which scoped style hashes are relative to. It defaults to the Vite root.devadds debug names to signals and memos, and keeps stylesheets unminified. It defaults to Vite's development mode.
Rollup, Rolldown, webpack, Rspack and esbuild have plugins too: solid-sfc/rollup, solid-sfc/rolldown, solid-sfc/webpack, solid-sfc/rspack and solid-sfc/esbuild. See Tooling.
Script
The <script> block becomes the body of the component function.
- Imports and type declarations are moved out of the function.
$props<T>()returns the component props.Tis the props type.- The
genericsattribute adds type parameters to the component, for example<script generics="T extends string">. - The script cannot export values. Put module-level code in
<script module>instead. It runs once, and it can export values.
<script module>
export const MAX = 10;
</script>
Compile-time functions
Compile-time functions are global functions that the compiler replaces with Solid 2.0 calls. They are the compile-time functions of solid-labels 1.0, and they work in <script>, <script module>, <style> interpolations and markup expressions.
<script>
let count = $signal(0);
const double = $memo(count * 2);
$effect(() => {
console.log(count, double);
});
function increment() {
count += 1;
}
</script>
<button onClick=increment>{count} * 2 = {double}</button>
$signal,$memoand$declare reactive variables. Reads become accessor calls, and writes to a signal become setter calls.$effectand$renderEffecttake one callback. The compiler builds the tracked compute phase from the variables that the callback reads.$destructurekeeps destructured values reactive. Rest elements useomit.$get,$set,$refSignal,$refMemo,$derefSignal,$derefMemo,$getter,$setterand$propertyconvert between reactive variables and accessors.- Other compile-time functions are renamed to the Solid 2.0 function and imported, for example
$settledtoonSettledand$storetocreateStore. For,Show,Switch,Match,Repeat,Errored,Loading,Reveal,Portal,Hydration,NoHydrationandHydrationScriptare imported when they are used without an import.
See the solid-labels documentation for the full list.
Destructuring $props() keeps the props reactive:
<script>
const { label = 'Click', ...rest } = $props<{ label?: string }>();
</script>
<button {...rest}>{label}</button>
Control flow
Control flow blocks start with # and a keyword. The block body is markup in { }.
#if (user) {
<p>Hi {user.name}</p>
} #else if (loading) {
<Spinner />
} #else {
<Login />
}
#for (item, index of items) {
<li>{index}: {item.name}</li>
} #else {
<p>No items</p>
}
| Block | Compiles to |
|---|---|
#if (a) { } #else if (b) { } #else { } |
<Show>, or <Switch> and <Match> with #else if. TypeScript narrows inside each branch. |
#for (item, index of list) { } #else { } |
<For each={list} fallback>. index is read without a call. |
#for (item of list by item.id) { } |
<For each={list} keyed={(item) => (item.id)}>. item is read without a call. |
#for (item, index of list by index) { } |
<For each={list} keyed={false}>. item is read without a call. |
#repeat (index of count) { } #else { } |
<Repeat count={count} fallback>. The index is optional: #repeat (3) { }. |
#switch { #case (a) { } #default { } } |
<Switch> and <Match>. #default must be last. |
#try { } #catch (error, reset) { } |
<Errored fallback>. error is read without a call. |
#loading (on) { } #else { } |
<Loading on fallback> |
#reveal (order, collapsed) { } |
<Reveal order collapsed> |
#portal (mount) { } |
<Portal mount> |
#no-hydration { } and #hydration (id) { } |
<NoHydration> and <Hydration id> |
#key (value) { } |
Renders the body again when value changes. |
Blocks can be nested, and they work inside elements and inside JSX in expressions.
# only starts a block when it is followed by a keyword and then ( or {, and when it does not come right after a letter or digit. Other text, such as #hashtag or C#, stays text. Write {'#'} to show a # that would start a block.
Style
<style> blocks only apply to the elements of the component.
- The compiler adds an
s-<hash>attribute to every native element in the markup. This includes elements passed as children to other components. - The last part of each selector gets the same attribute.
.list .itembecomes.list .item[s-<hash>]. :global(...)is not scoped.:global(.dark) .itemmatches.iteminside any.darkelement.<style global>is not scoped at all.@keyframesnames are scoped.animationandanimation-namein the same component use the scoped name.- The component adds its styles to the head while it is rendered, with
useStylefromsolid-sfc/runtime, and removes them when the last instance is removed. On the server they go into the head of the document.
${expression} in a declaration value sets a CSS variable. It updates when the expression changes.
<script>
let size = $signal(16);
</script>
<style>
p {
font-size: ${size}px;
}
</style>
The compiler sets the variables in the style attribute of each scoped element. An existing style attribute is merged with them. Interpolations are not allowed in selectors, at-rules or global styles.
Markup
Markup is JSX with these additions.
Names, literals, member accesses and calls do not need braces:
<button onClick=increment disabled=!valid tabIndex=0 />
<div id=`item-${id}` items=[1, 2] pattern=/a+/ />
<input value=count() title=items[0]?.label>
Expressions with operators or whitespace go in parentheses:
<a href=(base + path) />
Braces in an attribute value hold an object:
<div style={ color: 'red' } />
{name} is short for name={name}, as in a JavaScript object:
<a {href} />
[name] sets an attribute with a computed name:
<div [attribute]=value />
<div [attribute] />
Other changes:
<!-- comments -->are removed.- Void elements such as
<input>and<br>do not need/>. - Children still use
{expression}.
Compiler API
compile turns a .solid file into TSX and CSS. The TSX needs the SolidJS JSX compiler. It imports helpers from solid-sfc/runtime, such as useStyle when the file has styles.
import { compile } from 'solid-sfc';
const { code, map, css } = compile(source, {
filename: 'Counter.solid',
// Optional. Must be unique and stable across builds.
scope: 'counter',
});
// css is { code, map, lang }, or undefined without <style> blocks.
Syntax errors throw an SFCError with line and column.
Editor support
- VS Code extension: highlighting, type checking, hover, completion and go-to-definition.
@solid-sfc/tsc: type-checks.solidfiles from the command line withsolid-tsc --noEmit.@solid-sfc/language-server: the language server, for other editors.
Without these tools, tsc types imports of .solid files as Component<any>.
License
MIT lxsmnsyc