micro:bit apps UI is a small UI toolkit for building micro:bit apps: apps that run on the BBC micro:bit + Display Shield.
micro:bit apps UI gives an app a small screen runtime:
- Draw in a fixed
160x120pixel coordinate space. - Put each app page in a
UiScreen. - Push screens onto one
UiRuntime. - Queue semantic input events such as
up,down,activate, andcancel. - Start the runtime to deliver input, update the active screen, render it, and commit frames to the Display Shield.
You can draw directly in a screen, add screen-owned focusable views, or open modal UI such as the built-in numeric keypad.
The Display Shield is 160x120 pixels. micro:bit apps UI uses that same
coordinate space, with (0, 0) at the top-left corner.
class HelloScreen extends ui.UiScreen {
public render(surface: ui.DrawSurface): void {
surface.drawText("Hello", 19, 11, { color: 3 })
surface.drawRect(new ui.Rect(4, 4, 60, 22), 7)
super.render(surface)
}
}All drawing methods use palette color indices. The default palette matches
MakeCode Arcade's default palette.
Text, bitmaps, rectangles, lines, and circles are drawn through the DrawSurface
passed to render().
A typical app creates one runtime, pushes the first screen, and starts the runtime.
const runtime = new ui.UiRuntime(new ui.DisplayShieldFrameAdapter())
runtime.push(new HelloScreen(runtime))
runtime.start()start() registers a frame handler with the current MakeCode event context. It
delivers queued input to the active screen, calls the
screen's update(), calls render(), and commits the frame to the display
adapter. Call stop() when the app should stop drawing UI frames.
Screens receive the runtime in their constructor.
Screens are the normal place to keep page state and respond to input. Use
handleInput() when the screen wants first chance at an event.
class CounterScreen extends ui.UiScreen {
private count: number
private countLabel: ui.UiLabel
constructor(runtime: ui.UiRuntime) {
super(runtime)
this.count = 0
this.backgroundColor = 0
this.add(new ui.UiLabel("Count:", 1), { x: 8, y: 8 })
this.countLabel = new ui.UiLabel("0", 7)
this.add(this.countLabel, { x: 8, y: 24 })
}
public handleInput(event: ui.UiInputEvent): boolean | undefined {
if (event.phase == "released") return undefined
if (event.action == "activate") {
this.count += 1
} else if (event.action == "cancel") {
this.count = 0
} else {
return undefined
}
this.countLabel.setText("" + this.count)
return true
}
}Returning true from handleInput means the screen handled the event.
Returning undefined lets micro:bit apps UI try focus routing. While a
modal is open, the modal receives input before the screen.
The runtime works with semantic actions, not specific buttons. A
UiInputEvent names what the user meant to do: up, down, left, right,
activate, cancel, or menu.
Input events also include a source (board,
controller, or synthetic) and a phase (pressed, released, or repeated). Most screens only need action; use
phase when release events or key repeat should behave differently from the
initial press.
Call runtime.dispatchInput() from event callbacks or other application code. The runtime queues those events and delivers them on the next frame. When
no modal is open, the active screen gets first chance through handleInput(),
then the runtime tries focus routing when the screen returns undefined.
Map micro:bit button callbacks to the semantic actions your UI uses. For example, a simple two-button app can use A as activate and B as cancel.
input.onButtonPressed(Button.A, function () {
runtime.dispatchInput({
action: "activate",
source: "board",
})
})
input.onButtonPressed(Button.B, function () {
runtime.dispatchInput({
action: "cancel",
source: "board",
})
})UiLabel is the simplest way to keep text on a screen without writing a custom
render() method. UiButton owns activation and focus. Both are placed with
add().
class StartScreen extends ui.UiScreen {
private status: "Ready" | "Started" | "Stopped"
private statusLabel: ui.UiLabel
private toggleButton: ui.UiButton
constructor(runtime: ui.UiRuntime) {
super(runtime)
this.status = "Ready"
this.statusLabel = new ui.UiLabel(this.status, 1)
this.toggleButton = new ui.UiButton("start", "Start", () => {
this.status = this.status == "Started" ? "Stopped" : "Started"
this.statusLabel.setText(this.status)
this.toggleButton.setText(
this.status == "Started" ? "Stop" : "Start",
)
})
this.add(this.statusLabel, { x: 8, y: 8 })
this.add(this.toggleButton, { centerX: 80, centerY: 60 })
}
}Use UiButtonView directly only when you need the lower-level renderer. For
custom reusable controls, implement a UiFocusableView and add it to a screen
with add() or addCentered(). The screen will arrange it, register its focus
targets, route input to it, and render it each frame.
When a fixed control size is needed, use size: { width, height } on a single
button or label. Pickers use controlSize: { width, height } for repeated
control cells.
micro:bit apps UI includes a modal keypad for number entry. For positive integer entry, open it from a screen with an initial value and a completion handler.
class SettingsScreen extends ui.UiScreen {
private speed: number
private speedLabel: ui.UiLabel
constructor(runtime: ui.UiRuntime) {
super(runtime)
this.speed = 5
this.backgroundColor = 8
this.add(new ui.UiLabel("Speed", 1), { x: 8, y: 8 })
this.speedLabel = new ui.UiLabel("" + this.speed, 7)
this.add(this.speedLabel, { x: 8, y: 24 })
this.add(new ui.UiLabel("Press A to Edit", 1), {
centerX: 80,
y: 108,
})
}
public handleInput(event: ui.UiInputEvent): boolean | undefined {
if (event.action == "activate" && event.phase != "released") {
this.openSpeedEditor()
return true
}
return undefined
}
private openSpeedEditor(): void {
this.openModal(
new ui.UiNumericEntryModal("speed-editor", this.speed, value => {
this.speed = value
this.speedLabel.setText("" + value)
}),
)
}
}While a modal is open, the screen routes input to the modal first. The numeric
keypad uses the same semantic input actions as the rest of the runtime. OK emits
a completed result and closes the modal automatically. If a screen overrides
render(), call super.render(surface) after drawing the screen background and
content. The base render method draws screen-owned views and the active modal on
top of the screen.
- Prefer semantic input events inside screens instead of checking physical buttons in every screen.
- Keep one runtime for the app and push, pop, or replace screens as the user moves through the app.
- Reuse
Rect,Size, andUiMeasuredSizeobjects in frame code when practical. Avoid allocations in the render callback. - Use screen modals for short blocking tasks such as number entry and confirmation dialog.
micro:bit apps UI is a MakeCode extension. There are two normal ways to use it:
- Work in the MakeCode Editor when you want the in-editor project workflow.
- Work in VS Code when you want files on disk, source control, and command-line builds.
Use this workflow when you want to build in the browser and let MakeCode manage the project.
You need:
- The MakeCode editor for micro:bit.
- A BBC micro:bit and Display Shield when you want to run on hardware.
To add micro:bit apps UI:
-
Open
https://makecode.microbit.organd create or open a project. -
Open the Extensions window from the toolbox.
-
Paste this repository URL into the extension search box:
https://github.com/microbit-apps/user-interface-base -
Select the extension when MakeCode finds it.
-
Switch to JavaScript view and use the
uinamespace. -
The extension's toolbox category will be labeled
**micro:bit apps UI**.
Use this workflow when you want a local project folder that can be edited in VS Code and built from the command line.
You need:
-
The Microsoft MakeCode Arcade VS Code extension. Despite the name, it also works for micro:bit projects and is especially useful for running the MakeCode simulator from VS Code.
-
The MakeCode command-line tool:
npm install -g makecode
-
A BBC micro:bit and Display Shield when you want to run on hardware.
To create a new local micro:bit project:
mkc init microbit
mkc add https://github.com/microbit-apps/user-interface-base user-interface-base
mkc buildThen open the folder in VS Code. Use the MakeCode icon in the activity bar to open the MakeCode Action Palette. From there you can start the MakeCode simulator, install project dependencies, add extensions by GitHub URL, and build for hardware.
To add micro:bit apps UI to an existing local project, run this from the project folder:
mkc add https://github.com/microbit-apps/user-interface-base user-interface-base
mkc buildIf you add the extension from VS Code instead, use the MakeCode Extension's Add an Extension command and paste:
https://github.com/microbit-apps/user-interface-base
These examples show more small patterns you can copy into an app.
Controls can refer to bitmaps and labels by id. Provide an asset resolver when the runtime is created.
class AppAssets implements ui.UiAssetResolver {
public getBitmap(
id: string | number,
nullIfMissing?: boolean,
): Bitmap | undefined {
if (id == "start") {
return bmp`
. 7 .
7 7 7
. 7 .
`
}
if (nullIfMissing) return undefined
return bmp`.`
}
public getText(id: string): string {
if (id == "startLabel") return "Start"
return ""
}
}
const runtime = new ui.UiRuntime(
new ui.DisplayShieldFrameAdapter(),
0,
new AppAssets(),
)Screens can also keep bitmaps and strings as fields. Asset resolvers are most useful when reusable controls need stable ids instead of direct values.
Use UiPicker for simple modal choices. It owns modal focus, button layout,
directional navigation, rendering, activation, and cancel handling.
class SaveScreen extends ui.UiScreen {
private status: string
private statusLabel: ui.UiLabel
constructor(runtime: ui.UiRuntime) {
super(runtime)
this.status = "Not saved"
this.backgroundColor = 8
this.statusLabel = new ui.UiLabel(`Status: ${this.status}`, 7)
this.add(this.statusLabel, { x: 8, y: 8 })
this.add(new ui.UiLabel("Press A to save", 1), { x: 8, y: 18 })
}
public handleInput(event: ui.UiInputEvent): boolean | undefined {
if (event.action == "activate" && event.phase != "released") {
this.openConfirmDialog()
return true
}
return undefined
}
private openConfirmDialog(): void {
const modal = new ui.UiPicker(
"save-dialog",
"Save changes?",
["Cancel", "OK"],
choice => {
this.status = choice == "OK" ? "Saved" : "Cancelled"
this.statusLabel.setText(`Status: ${this.status}`)
},
() => {
this.status = "Cancelled"
this.statusLabel.setText(`Status: ${this.status}`)
},
)
this.openModal(modal)
}
}Use UiTextEntryModal for short strings such as names, labels, or titles. The
modal owns the compact keyboard, and the screen updates its state when the modal
returns a completed result.
class NameEntryScreen extends ui.UiScreen {
private name: string
private nameLabel: ui.UiLabel
constructor(runtime: ui.UiRuntime) {
super(runtime)
this.name = ""
this.backgroundColor = 8
this.add(new ui.UiLabel("Player", 1), { x: 8, y: 8 })
this.nameLabel = new ui.UiLabel("No name", 7)
this.add(this.nameLabel, { x: 8, y: 24 })
this.add(new ui.UiLabel("Press A to enter", 1), {
centerX: 80,
y: 108,
})
}
public handleInput(event: ui.UiInputEvent): boolean | undefined {
if (event.action == "activate" && event.phase != "released") {
this.openNameEditor()
return true
}
return undefined
}
private openNameEditor(): void {
this.openModal(
new ui.UiTextEntryModal({
modalScopeId: "name-editor",
title: "Enter your name:",
initialText: this.name,
allowWhitespace: true,
allowSymbols: true,
maxLength: 16,
onResult: result => {
if (result.kind == "completed") {
this.name = result.text
this.nameLabel.setText(
this.name.length ? this.name : "No name",
)
}
},
}),
)
}
}For custom visualization, keep the data and controls in the screen, update it
over time, and draw directly to the DrawSurface.
class DataGraphScreen extends ui.UiScreen {
private values: number[]
private tick: number
private graphRect: ui.Rect
private valueLabel: ui.UiLabel
private toggleButton: ui.UiButton
private running: boolean
constructor(runtime: ui.UiRuntime) {
super(runtime)
this.backgroundColor = 0
this.tick = 0
this.running = true
this.graphRect = new ui.Rect(8, 22, 144, 70)
this.add(new ui.UiLabel("Signal", 1), { x: 8, y: 6 })
this.toggleButton = new ui.UiButton("toggle", "Stop", () => {
this.running = !this.running
this.toggleButton.setText(this.running ? "Stop" : "Start")
})
this.values = [
24, 28, 35, 40, 46, 52, 58, 63, 68, 72, 70, 66, 60, 54, 48, 42, 36,
31, 27, 25,
]
this.valueLabel = new ui.UiLabel(
"" + this.values[this.values.length - 1],
7,
)
this.add(this.valueLabel, { x: 128, y: 6 })
this.add(this.toggleButton, { centerX: 80, centerY: 107 })
}
public update(): void {
if (!this.running) return
this.tick += 1
if (this.tick % 6 != 0) return
const phase = Math.idiv(this.tick, 6) % 20
const wave = phase < 10 ? phase : 20 - phase
this.values.removeAt(0)
this.values.push(25 + wave * 6)
this.valueLabel.setText("" + this.values[this.values.length - 1])
}
public render(surface: ui.DrawSurface): void {
surface.drawRect(this.graphRect, 1)
surface.drawLine(
this.graphRect.x + 1,
this.graphRect.y + Math.idiv(this.graphRect.height, 2),
this.graphRect.x + this.graphRect.width - 2,
this.graphRect.y + Math.idiv(this.graphRect.height, 2),
13,
)
let previousX = 0
let previousY = 0
for (let i = 0; i < this.values.length; i++) {
const x =
this.graphRect.x +
2 +
Math.idiv(
i * (this.graphRect.width - 4),
this.values.length - 1,
)
const y =
this.graphRect.y +
this.graphRect.height -
3 -
Math.idiv(this.values[i] * (this.graphRect.height - 6), 100)
if (i > 0) surface.drawLine(previousX, previousY, x, y, 7)
previousX = x
previousY = y
}
super.render(surface)
}
}These micro:bit apps projects use user-interface-base and are useful references when you want to see the library in action:


