Tutorial: 3. Components and styles

Tutorial

3. Components and styles

The row markup is about to grow. In this step you'll move it into a BookRow component with its own styles, and give books an author.

A shared type

Both the page and the new component need the Book shape. Create src/lib/books.ts:

src/lib/books.ts
export interface Book {
	id: number;
	title: string;
	author: string;
	read: boolean;
}

src/lib is just a folder. Only src/routes has special meaning.

The component

Create src/lib/BookRow.tsx:

src/lib/BookRow.tsx
import type { Book } from "./books";

export const BookRow = ({ book }: { book: Book }) => (
	<li class={book.read ? "book read" : "book"}>
		<label>
			<input type="checkbox" bind:checked={book.read} />
			<span class="title">{book.title}</span>
		</label>
		<span class="author">{book.author}</span>
	</li>
);

<style>
	.book {
		display: flex;
		justify-content: space-between;
		padding: 0.5rem 0;
		border-bottom: 1px solid #ddd;
	}
	.read .title {
		text-decoration: line-through;
		color: #888;
	}
	.author {
		color: #666;
	}
</style>

A component is a function that takes props and returns JSX. Its name starts with a capital letter, which is how JSX tells <BookRow> apart from an element like <li>.

Props stay live. book is the same object that sits in the page's books array, so when the checkbox sets book.read, the row's class and the page's count both update. That holds for any prop: pass state in, and the component follows it.

Scoped styles

A <style> block in a component file is scoped to that file. The compiler adds a class unique to the file to every element the file renders and rewrites each selector to require it, so .title here can't collide with a .title anywhere else. The CSS is extracted at build time and added to the page once, however many rows render.

Scoping stops at the file boundary: these rules don't reach into child components, and a parent's styles don't reach in here. Wrap a selector in :global(…) when you need to style something outside the file.

Use it

Update src/routes/index.tsx:

src/routes/index.tsx
import { BookRow } from "../lib/BookRow";
import type { Book } from "../lib/books";

let books = $state<Book[]>([
	{ id: 1, title: "The Left Hand of Darkness", author: "Ursula K. Le Guin", read: true },
	{ id: 2, title: "Piranesi", author: "Susanna Clarke", read: false },
]);
let title = $state("");
let author = $state("");
let nextId = 3;
let unread = $derived(books.filter((b) => !b.read).length);

function add(e: SubmitEvent) {
	e.preventDefault();
	if (!title.trim()) return;
	books.push({ id: nextId++, title: title.trim(), author: author.trim(), read: false });
	title = "";
	author = "";
}

const Index = () => (
	<main>
		<h1>Reading list</h1>
		<form onSubmit={add}>
			<input placeholder="Title" bind:value={title} />
			<input placeholder="Author" bind:value={author} />
			<button>Add</button>
		</form>
		<ul>
			{books.map((book) => (
				<BookRow key={book.id} book={book} />
			))}
		</ul>
		<p>{unread} still to read.</p>
	</main>
);

export default Index;

The key goes on the component, where the list can see it. Read books are now struck through.

Children

Components can also take JSX between their tags. It arrives as the children prop:

const Card = ({ title, children }: { title: string; children?: unknown }) => (
	<section class="card">
		<h2>{title}</h2>
		{children}
	</section>
);

export const Example = () => (
	<Card title="Currently reading">
		<p>Piranesi</p>
	</Card>
);

Next: pages and layouts.

Edit this page