JavaScript Projects for Practice: 18 Ideas With Specs
JavaScript Projects for Practice: 18 Ideas With Specs
You finished a to-do list. It worked. Two weeks later you could not have written it again without opening the tutorial.
That is not a discipline problem. A project only teaches you the parts you had to produce from memory, and following along produces almost nothing from memory. The fix is not a longer list of app ideas. It is knowing which concept a given app forces, and building against a written spec instead of a video.
This article gives eighteen projects in three tiers. Each one names the JavaScript concept it exercises, the acceptance criteria you build against, and the specific failure it hides.
How to pick a project that teaches you something
Here is the selection rule. A practice project is worth your time only if it forces at least one thing you cannot currently write without looking it up. Not one thing you have never heard of, and not one thing you already know cold. One thing you half-know.
Three tiers run through the rest of this article, and they are defined by what the project is allowed to contain, not by how impressive it sounds.
Drill is under two hours, one concept, no persistence and no network. Nothing is saved between reloads and nothing is fetched. A drill exists so the concept has nowhere to hide behind plumbing.
Build is a weekend. At least two of state, persistence and the network, in one app. The difficulty moves from “does the function work” to “what is the shape of the data, when does it get written, and what shows on screen while it is loading.”
Stretch is several sessions. Architecture, performance, tooling or Node. The gap being closed is structural rather than syntactic, which is why the Stretch list contains things that are not web pages at all.
Say you can already write a keydown handler without thinking. Then the drum kit is not a project for you, it is typing practice. Skip to the tier where something still feels vague. The tiers are not a ladder you climb in order, they are a filter you apply to your own gaps.
One more rule before the list. Pick the project by the concept, then let the theme be whatever amuses you. Event delegation is event delegation whether the widget is an accordion of FAQs or a set of tabs on a fantasy-league page. The theme is what gets you to the end; the concept is why the end was worth reaching.
Drill projects: one concept, under two hours
Seven of these. No fetch, no localStorage. If you find yourself adding either, you have left the tier and blurred what the exercise was testing.
Counter with a step size and undo
Forces: closure over mutable state. The counter is not a variable in the global scope, it is a variable captured by the functions that read and change it, and undo means keeping a history array inside the same closure.
createCounter(start, step)returns an object withincrement,decrement,undoandvalue, and nothing else can reach the count.- Two counters created from the same factory do not share state.
undoreverses the last operation and is a no-op at the start of history.- Step size is set at creation and cannot be changed from outside.
The trap: putting the count on this and then losing this when the method is passed to addEventListener. Build it once with a closure and once with a class to feel the difference. Counter in JavaScript: Build One Properly covers the shape in detail.
Drum kit or keyboard shortcut layer
Forces: the event object, and knowing which keyboard event to listen for. Read event.key and event.code side by side and notice they differ. keydown fires for keys that produce no character; keypress is deprecated and should not appear in new code.
- Pressing a mapped key plays its sound and highlights its pad.
- Holding the key does not retrigger endlessly (handle
event.repeat). - Shortcuts do not fire while focus is in a text input.
- Pads are mapped by
event.code, so the same physical keys work on a QWERTY and an AZERTY layout; text shortcuts are matched byevent.key.
Accordion or tab strip
Forces: event delegation and a single source of truth. One listener on the container, not one per header, and the open panel stored in a variable that the render reads from.
- One click handler total, regardless of how many panels exist.
- Panels added to the DOM after load work without rebinding anything.
- Open state lives in one place; the DOM is derived from it, never queried for it.
- Only one panel open at a time, enforced by the state, not by closing the others by hand.
Star rating widget
Forces: the split between DOM state and visual state. Hovering changes what you see without changing what is selected. Two different values, one set of elements.
- Hover previews a rating; moving away restores the committed value.
- Keyboard arrows change the rating and Enter commits it.
- The current value is exposed to assistive technology (a radio group, or
roleplusaria-checked). - Clicking the current rating again clears it to zero.
Stopwatch with laps
Forces: timer drift. setInterval(fn, 10) does not fire every ten milliseconds, and adding ten to a counter each tick produces a clock that is visibly wrong after a minute. Store a start timestamp and compute elapsed time from it on every tick.
- Elapsed time is derived from timestamps, never accumulated from the interval.
- Pause and resume do not lose time or double-count it.
- Laps record split times and total times separately.
- The display updates smoothly without the number jittering between values.
Rewrite the render loop with requestAnimationFrame afterwards and compare; JavaScript animations explains why the frame callback is the right hook for anything that paints.
Unit converter
Forces: input parsing. This is the least glamorous drill and the one that catches the most people, because user input is a string and strings convert in more than one way.
console.log(parseFloat('12abc'), Number('12abc'));
console.log(Number(''), parseFloat(''));
console.log(Number.isNaN(Number('abc')));
12 NaN
0 NaN
true
parseFloat reads as far as it can and stops. Number demands the whole string be numeric, and cheerfully turns an empty string into 0, which is how a blank field becomes a valid conversion. Acceptance criteria: empty input shows the empty state rather than zero, non-numeric input shows a message rather than NaN, negative and decimal values both work, and the conversion is correct in both directions. Numbers covers the conversion rules underneath.
Card deck and dealer
Forces: array methods and a correct shuffle. Build a 52-card deck, shuffle it, deal hands, and never mutate the original deck when a copy was intended.
- The deck is generated from suits and ranks, not typed out.
- The shuffle is Fisher-Yates, walking backwards and swapping with a random earlier index.
- Dealing removes cards from the deck; the counts always add up.
- Dealing from an empty deck fails loudly rather than returning
undefinedcards.
The trap is deck.sort(() => Math.random() - 0.5). It looks like a shuffle and is not one: the comparator is inconsistent, so it violates what sort assumes about ordering, and the resulting permutations are not uniformly distributed. The bias depends on the engine’s sort algorithm, which is exactly why you should not rely on it. Arrays is the reference for the methods involved.
Build projects: state, persistence and the network
Six projects, a weekend each. Most of them have state that outlives a reload and most of them talk to something over HTTP. This is the tier where “it works on my machine with three items” stops being enough.
To-do app with localStorage
Forces: serialization. Web Storage stores strings and only strings. Hand it an object and it stores the object’s string form:
const user = { name: 'Alex' };
console.log(String(user));
console.log(JSON.parse(JSON.stringify(user)).name);
[object Object]
Alex
localStorage.setItem('user', { name: 'Alex' }) stores "[object Object]", silently, and you find out on the next reload when every task is identical and nameless. Everything goes through JSON.stringify on the way in and JSON.parse on the way out.
Two more things this project hides. setItem throws a QuotaExceededError when the quota is exhausted or the user refuses more space, so a write can fail and yours should catch it. And Web Storage is synchronous: reads and writes block other JavaScript, which is why MDN points at IndexedDB for larger amounts of data. Criteria: tasks survive a reload, a corrupt or absent storage value falls back to an empty list rather than crashing, editing a task does not lose its id, and a failed write shows something to the user.
Search-as-you-type against a public API
Forces: debouncing and cancellation. A fast typist fires a request per keystroke, and the responses come back in whatever order the network feels like. Watch what that does:
const wait = (ms, v) => new Promise(r => setTimeout(() => r(v), ms));
wait(30, 'results for: ma').then(v => console.log(v));
wait(10, 'results for: mari').then(v => console.log(v));
results for: mari
results for: ma
The user typed mari, and the screen ends up showing results for ma, because the earlier request finished last. Debouncing reduces the number of requests; it does not fix the ordering. You need cancellation: keep an AbortController for the in-flight request, call abort() before starting the next one, pass its signal to fetch, and treat the resulting AbortError as normal rather than as a failure.
- No request fires until typing pauses.
- Starting a new search cancels the previous one.
- An aborted request never renders and never shows an error.
- Empty results and network errors are two different screens.
Expense tracker
Forces: money arithmetic and formatting. Floating point is not a currency type:
console.log(0.1 + 0.2);
console.log(0.1 + 0.2 === 0.3);
console.log((10 + 20) / 100);
0.30000000000000004
false
0.3
Store amounts as integer cents, do all arithmetic in integers, and divide only at the moment of display. Format with Intl.NumberFormat, which knows where the currency symbol goes and which separators the locale uses. Criteria: totals are exact for a thousand entries, the currency is configurable, amounts parse from user input in the local decimal format, and category totals sum to the grand total.
Quiz app with a score screen
Forces: rendering from one state object. The temptation is to update the DOM in place as answers come in. Instead keep { questions, currentIndex, answers, phase } and write one render function that draws the whole screen from it.
- Every screen (question, review, score) is a value of
phase, not a separate code path that hides elements. - The back button re-renders a previous question with the answer already selected.
- Reloading mid-quiz either resumes from storage or starts clean, deliberately, not by accident.
- The score is computed from
answers, never incremented as you go.
Weather dashboard
Forces: loading and error states as real UI. Every request has at least four outcomes: pending, success, empty, failed. Most tutorial weather apps render one and console.log the rest.
- A skeleton or spinner shows while the request is in flight, including on a slow connection you simulate by throttling.
- A 404 for an unknown city and a 500 from the server produce different messages.
- Losing the network mid-request shows a retry affordance that actually retries.
- The last successful result stays on screen during a refresh rather than blanking.
Markdown note editor
Forces: controlled rendering and escaping. Typing into a textarea and setting innerHTML from the parsed output means anything the user types becomes live markup, which is where the injection lives. Render text nodes for text, build elements for structure, and never concatenate untrusted input into an HTML string.
- Preview updates as you type without the cursor jumping.
- A note containing
<script>or<img onerror=...>displays as text. - Notes persist and the currently open note survives a reload.
- Renaming a note updates the list without a full re-render of the editor.
That is six projects covering the whole persistence and network story. If you want the underlying reference material alongside the building, the complete bundle is the same course as downloadable PDFs, which is easier to consult offline while you have an editor open.
Stretch projects: architecture, performance and Node
Five projects for developers whose gaps are structural rather than syntactic. These need several sessions, and two of them never touch a browser.
Virtualised list of 50,000 rows
Forces: understanding layout cost. Fifty thousand DOM nodes will not stay smooth. Render only the rows in the viewport plus a small buffer, translate a spacer to keep the scrollbar honest, and recycle nodes as the user scrolls.
The trap is layout thrash: reading offsetHeight and then writing a style, in a loop, forces the browser to recalculate layout on every iteration. Batch all reads, then all writes, and drive updates from requestAnimationFrame rather than the raw scroll event. Done when scrolling holds a steady frame rate, jumping to the end is instant, and variable-height rows still land in the right place.
Offline-first notes app on IndexedDB and a service worker
Forces: asynchronous storage. This is where localStorage genuinely stops being the answer: it is synchronous and small, and MDN recommends IndexedDB when there is real data to hold. IndexedDB’s API is request-and-event based: a read returns an IDBRequest whose value only arrives in an onsuccess handler, so you either write callback plumbing or wrap each request in a promise yourself.
A service worker adds a second axis: your code is now running in two places with different lifetimes. Done when the app opens with no network, edits made offline appear after a reload, and a sync on reconnect resolves a conflict in a way you chose and wrote down.
A Node CLI you actually use
Forces: the runtime outside the browser. Pick something you do by hand: renaming a folder of files, summarising a CSV, checking a set of URLs. Parse process.argv yourself before reaching for a library, so you learn what the shape of it is.
--helpprints usage and exits with code 0; a bad flag prints usage to stderr and exits non-zero.- Large input is streamed rather than read entirely into memory.
- Output to a pipe is machine-readable; output to a terminal may be pretty.
- The tool is installable and runnable by name.
Node.js, Deno & Bun covers the runtime differences if you want to run the same CLI on more than one.
A promise implementation from scratch
Forces: microtask ordering. Write a MyPromise class with then, catch, resolution, rejection and chaining, then check your ordering against the real thing:
console.log('script start');
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('microtask'));
console.log('script end');
script start
script end
microtask
timeout
Both callbacks were scheduled before either ran, and the microtask still went first. Your implementation has to reproduce that: a then callback never runs synchronously, even when the promise is already settled. Done when a chain of ten then calls resolves in order, a throw inside a handler rejects the next link, returning a thenable from a handler adopts its state, and your ordering matches native output on the same test script.
A 200-line test runner
Forces: functions as values, and error capture. describe and it collect functions into a tree; running the tree means calling them, catching what they throw, and reporting. Async tests mean awaiting each one and enforcing a timeout so a hung test does not hang the run.
Done when nested describe blocks report indented, a failing assertion prints the expected and actual values, an async test that never resolves fails on timeout rather than stalling, and the process exit code reflects the result. Then read the docs for a real runner and see what you missed; Testing: Vitest & Playwright is the comparison point.
Write the spec before you write the code
This is the method that makes building without a tutorial possible. Before the editor opens, write four things down. It takes ten minutes.
Acceptance criteria. Three to six plain sentences, each one observable. “Adding a task shows it in the list immediately” is a criterion. “Good UX” is not.
Edge cases. A written list, not a vague intention. Empty state, duplicate entry, very long input, offline, a 500 response, a thousand items.
Definition of done. The point at which you stop, stated in advance so that scope cannot quietly grow.
Timebox. Two hours, a weekend, three sessions. When the box is spent, you ship what exists and write down what you cut.
Here is the format applied to the to-do app, so you can copy the shape rather than the code.
To-do app, v1. Timebox: one weekend.
Criteria: adding a non-empty task shows it at the top of the list; clicking a task toggles it complete and moves it below the active ones; the list is identical after a reload; deleting a task asks for no confirmation but can be undone once; the header shows a live count of active tasks.
Edge cases: empty or whitespace-only input is rejected without an error dialog; a task of 500 characters wraps rather than overflowing; storage that fails to parse resets to an empty list and warns; a failed write shows a banner; 1,000 tasks render in under a second.
Done when: all five criteria pass by hand in two browsers, every edge case above has a deliberate behaviour, and no
console.logremains.Not in v1: due dates, tags, drag to reorder, sync.
The “not in v1” line is the one that saves the weekend. Everything you think of while building goes there instead of into the build.
The second version is where the learning happens
The instinct after finishing something is to start a new app. Rebuild the old one instead, under a constraint. The domain is already understood, so all of the difficulty is in the constraint, which is exactly where the learning is.
A ladder, roughly in increasing order of difficulty:
- Remove one dependency. Date library, HTTP wrapper, whatever it is. Write the twenty lines you were using it for.
- Make it fully keyboard-operable. Every action reachable by Tab and Enter, visible focus rings, focus moved deliberately after a delete or a dialog close.
- Make it survive 10,000 items. Where does it get slow, and did you guess right before you measured?
- Make it work offline. Now the storage layer and the network layer have to be separable.
- Split it into ES modules with no circular imports. This forces real boundaries; a circular import is a design problem announcing itself.
- Add tests. If the code resists testing, that is the finding, not an obstacle.
- Port it to a framework. Then write down, in sentences, what the framework took over. That list is worth more than the port.
Each rung is a session or two, and each one produces a specific, nameable thing you learned. “I rewrote my expense tracker without the date library” is a better answer in an interview than “I built five apps.”
Where to get projects you didn’t invent
Inventing project ideas is its own tax. Several places hand you briefs, and the trick is using them without sliding into copy-along.
freeCodeCamp’s beginner round-up by Jessica Wilkins, published 24 March 2021, lists 40 projects: 27 in vanilla JavaScript, 11 in React and 2 in TypeScript, each paired with a video tutorial. Use the project names and skip the videos on the first pass.
JavaScript30 is Wes Bos’s free course of 30 challenges built over 30 days, advertised as “No Frameworks × No Compilers × No Libraries × No Boilerplate”. The constraint is the value: every challenge is small enough to hold in your head and forces real DOM and event work.
Frontend Mentor advertises 120+ challenges across difficulty levels. The briefs supply the design and the assets and leave the JavaScript behaviour entirely to you, which is unusual and useful. Free members get JPEG designs of the desktop and mobile views plus a style guide with colours, font families and a base font size; Pro members get the original Figma file with exact measurements. The free tier is enough to build against.
Codecademy publishes a project list, and 100jsprojects collects small builds with source. Exercism has a JavaScript track of exercises with mentoring, which is a different shape: small, tested problems rather than apps.
The protocol for all of them is the same three steps, and it is what separates practice from transcription:
- Read the brief, then close the tab. Write your own spec in the format above.
- Build it. Look up APIs freely, in the docs. Do not look at their implementation.
- Diff. Now open their solution and compare, decision by decision. Where they differ from you, work out which is better and why, and note anything you did not know existed.
Step three is the whole point. A solution you read before building teaches you what the code looks like. A solution you read after building teaches you what you were missing, and that is a much shorter, much more useful list.