Skip to content

Decky UI Learnings

Michael DeGuzis edited this page May 29, 2026 · 6 revisions

Decky UI Learnings

Working notes from Proton Pulse's on-device Steam Deck UI iteration. This page is meant to stay practical. It records what actually worked, what broke, and what looked off in the real Decky/Steam environment, especially around Manage This Game.

Main Takeaways

  • Prefer a single flat right-side content surface over nested rounded panels.
  • Treat Decky focus as a real systems problem, not just a styling problem.
  • Remove hidden content from layout when showing overlays. Fading it out is not enough.
  • Keep only real interactive elements focusable. Container focus produces large, ugly highlight blocks.
  • Tight toolbar layouts should avoid Decky DropdownItem when horizontal space is constrained.
  • Real scrolling requires a truly bounded scroll container. If scrollHeight === clientHeight, the joystick has nothing to scroll.

Flat Surfaces Beat Nested Cards

The clearest lesson from comparing Proton Pulse to Wine Cellar is that Steam/Decky UI looks better when the right pane behaves like one continuous sheet.

Patterns that worked better:

  • one dark background for the whole right pane
  • section separation via thin divider lines
  • simple stacked rows for metadata
  • minimal chrome around content

Patterns that looked worse:

  • panel-inside-panel layouts
  • multiple rounded cards stacked within another rounded shell
  • slightly different dark background colors for parent and child surfaces
  • decorative wrapper boxes around mostly textual information

Practical result:

  • Manage This Game detail screens should aim for a Wine Cellar-like structure:
    • header
    • action row
    • flat info rows
    • report text sections

Focus Behavior Is the Hard Part

Decky focus behavior can look fine in desktop-style React code and still fall apart on a real Deck.

Observed problems:

  • focus leaking back to the left sidebar while the right content detail view is open
  • giant white focus rectangles appearing on container elements instead of buttons/rows
  • directional movement being handled at the wrong layer
  • action rows trapping focus horizontally instead of handing off into page content

What helped:

  • make only real controls focusable
  • hide or cover the sidebar while detail mode is open
  • wire B as the intentional exit path from detail mode
  • explicitly move focus to the first actionable element when opening overlays
  • explicitly send Down from toolbar controls into cards
  • explicitly send Down from action rows into content

What did not help enough on its own:

  • visual-only focus styling
  • leaving parent/root Focusable wrappers to guess navigation correctly
  • simply making the overlay visible without changing layout/focus ownership

Hidden Content Must Leave Layout

One big source of broken scrolling and ghost focus was leaving the card list in layout while the detail overlay was open.

Bad pattern:

  • opacity: 0
  • pointer-events: none

That still allows the hidden area to contribute to layout/height calculations.

Better pattern:

  • display: none while the overlay is open

This was important because the detail view otherwise behaved like an infinitely tall surface instead of a bounded page.

Real Scroll Containers Matter

The right joystick and directional scroll behavior only work if the detail content lives inside a real bounded scroll region.

Symptoms of a fake/non-bounded scroll area:

  • no visible scrollbar
  • right joystick appears to do nothing
  • Down navigation loops in action buttons
  • logs show detailScrollHeight and detailClientHeight are effectively the same giant value

Requirements for a useful detail scroll pane:

  • fixed or inherited real height from parent
  • overflow-y: auto
  • hidden background content removed from layout
  • no wrapper above it expanding to the full content height

Tight Horizontal Toolbars

Decky DropdownItem looked fine on its own but became awkward in a compact horizontal toolbar.

Issues:

  • internal chrome was too large for Sort + GPU on one line
  • labels overlapped or clipped
  • focus styling made the toolbar feel bloated

Better pattern:

  • compact custom menu buttons
  • separate labels outside the button shell
  • horizontal movement wired explicitly
  • Down from the toolbar goes directly to the card list

Detail Rows Should Read Like Rows

The detail page got better once Game, Launch Preview, and Current Launch Options stopped acting like separate mini-cards and started reading like structured rows.

Better:

  • label column + value column
  • divider lines
  • one continuous content surface

Worse:

  • three adjacent rounded boxes
  • large empty gutters
  • focus landing on a whole wrapper instead of a specific row/button

Overlay Entry and Exit Rules

The expected model for Manage This Game detail is:

  • card select opens detail at the top
  • detail keeps focus on the right-side content
  • B backs out one level
  • detail should not casually leak focus back to the sidebar

This means:

  • on open, force scroll to top
  • move focus to the first action or intended top row
  • while detail is open, visually and behaviorally isolate the left nav
  • on B, return to list state, not directly out of the plugin

Background/Color Matching

Even small differences in dark shades can make the right pane look inset instead of fully open.

Practical lesson:

  • use the same background color for overlay shell and inner content sheet
  • avoid subtle blue-vs-black mismatches if the goal is a flat Steam-native look

If the user can still see a "panel" inside the page, there is probably still:

  • a different background color
  • a hidden border
  • a residual shadow
  • or extra padding framing the inner content

Settings Tab Lessons

The Settings page also looked better once it moved away from stacked rounded cards and toward flatter sections.

Observed issues:

  • native ToggleField focus surfaces can bleed wider than the intended visual row
  • nested cards in Compatibility Tools looked unlike Wine Cellar
  • inner blocks needed to flatten into divider-based sections

Helpful adjustments:

  • clip wide toggle focus surfaces inside narrower wrappers
  • flatten inner compatibility sections
  • keep the whole right pane visually consistent

Proton Tooling Lessons

The Settings UI was not the only problem. On-device logs showed release fetching was failing separately from layout.

Important findings:

  • installed Proton compatibility tools can still be detected even when available release fetching fails
  • Deck Python urllib hit SSL: CERTIFICATE_VERIFY_FAILED
  • a fallback path is needed for real Deck reliability
  • Wine Cellar uses a separate Rust backend with reqwest, plus caching

Implication:

  • UI should distinguish between:
    • installed tools detected successfully
    • remote release feed unavailable

Toast Sound Muting Does Not Work Via playSound or info.sound

Decky Loader's toaster.toast() API exposes playSound?: boolean and sound?: number fields, but neither silences the toast sound in practice. Here is why.

What Decky actually does

When toaster.toast(args) is called, Decky Loader:

  1. Defaults toast.sound = 6 if not set, and toast.playSound = true if not set.
  2. Constructs an info object that contains sound: toast.sound but does not include playSound.
  3. Calls window.NotificationStore.ProcessNotification(info, toastData, ToastType.New).

The info object never contains playSound.

Where the sound actually gets played

When Steam renders the toast, the React component calls:

useEffect(() => { e && NotificationStore.PlayNotificationSound(e) }, [e])

PlayNotificationSound(toastData) does:

const t = toastData.eType;
const r = W[t];                  // looks up Steam's hardcoded notification type map
const i = ChooseSound(r, toastData);
i && PlayNavSound(i);

W is Steam's internal map of notification types. For eType 31 (the Decky default), W[31] has sound: ToastMisc and no playSound field, so ChooseSound resolves to true and the sound always plays -- regardless of what we put in info.sound or playSound.

ChooseSound(e, t) logic:

const r = e.playSound ?? !!e.sound;
if ((play_sound_on_toast || bCritical) && (typeof r === 'boolean' && r || typeof r === 'function' && r(...)))
    return e.sound || ...;
return null;

With W[31]: r = undefined ?? !!(ToastMisc) = true -- sound plays.

The working fix

Pass eType: 40 when sound should be muted. W[40] ("Steam Input Action Set") has playSound: false:

W[40] = { showToast: true, playSound: false, eFeature: l.uX, ... }

With eType 40: r = false ?? ... = false -- ChooseSound returns null, no sound plays.

Decky still shows the toast normally because it uses its own info object (with showToast: true, your title/body) for rendering. The eType only affects the PlayNotificationSound lookup.

In src/lib/notify.ts:

deckyToaster.toast({ ...args, playSound: soundOn, ...(soundOn ? {} : { sound: 0, eType: 40 }) });

sound: 0 handles the ChooseSound(info, ...) path (which uses info.sound); eType: 40 handles the PlayNotificationSound path (which uses W[eType]).

Subtle Row Focus Indicator Pattern

Steam's default DialogButton focus styling can be suppressed or invisible depending on inline style overrides. A reliable, visually consistent alternative is a thin colored right-side border tracked via React state.

Pattern

const [focusedRow, setFocusedRow] = useState<string | null>(null);

<DialogButton
  onClick={() => {}}
  onFocus={() => setFocusedRow('row-key')}
  onBlur={() => setFocusedRow(null)}
  style={{
    borderRight: focusedRow === 'row-key' ? '3px solid #1a9fff' : '3px solid transparent',
  }}
>
  ...
</DialogButton>
  • Use a unique string key per row (e.g. plat-Windows, req-0, hdr-platform).
  • transparent keeps layout stable -- no reflow when focus changes.
  • Works on section header rows too: make them DialogButton with the same pattern instead of a plain div.
  • Replace invisible anchor DialogButton elements (height:0, opacity:0) with visible focusable section headers so D-pad navigation has no silent gaps.

Why right side, not left

The left side is used by the current-game highlight indicator (borderLeft: '3px solid #4c9eff'). Using the right side avoids collision with that existing affordance.


Modal Archetypes (Proton Pulse -- confirmed on device)

Two patterns cover all modals in this plugin. Pick based on whether you need fullscreen or standard sizing.

Fullscreen modal

Use for complex forms and viewers (EditReportModal, ReportDetailModal, ConfigEditorModal, LogViewerModal, ScoringGuideModal, NativePulseReportModal).

<ModalRoot onCancel={closeModal} bAllowFullSize
  className="pp-my-modal" modalClassName="pp-my-modal">
  <style>{`
    .pp-my-modal, .pp-my-modal > div, .pp-my-modal .DialogContent_InnerWidth {
      padding: 0 !important; margin: 0 !important;
      max-width: 100vw !important; width: 100vw !important; max-height: 100vh !important;
    }
    .pp-my-modal .ModalPosition { inset: 0 !important; }
  `}</style>
  <div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 40px)', padding: '12px 16px 0' }}>
    <div style={{ flexShrink: 0 }}>{/* fixed header */}</div>
    {/* scroll body -- see scroll section below */}
    <div style={{ flexShrink: 0 }}>{/* fixed footer buttons */}</div>
  </div>
</ModalRoot>

CSS class names must be unique per modal -- class collisions break the fullscreen hack.

Standard (non-fullscreen) modal

Use for simple confirmations, small info dialogs.

<ModalRoot onCancel={closeModal}>
  <div style={{ padding: 16, maxHeight: '85vh', display: 'flex', flexDirection: 'column', gap: 0 }}>
    <div style={{ flexShrink: 0 }}>{/* header */}</div>
    <Focusable style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
      {/* content */}
    </Focusable>
    <div style={{ flexShrink: 0 }}>{/* footer */}</div>
  </div>
</ModalRoot>

maxHeight: 85vh is required -- without it, flex: 1 on Focusable has no height constraint and scroll never activates.


Scrollable Content in Modals

Static content scroll (onGamepadDirection -- confirmed working)

Use when the scroll area has no interactive DropdownItems/buttons as direct children. Focusable alone does NOT enable gamepad scroll without interactive children.

const scrollRef = useRef<HTMLDivElement>(null);
const handleGamepadDirection = (e: GamepadEvent) => {
  if (e.detail.button === GamepadButton.DIR_DOWN) scrollRef.current?.scrollBy({ top: 80, behavior: 'smooth' });
  if (e.detail.button === GamepadButton.DIR_UP)   scrollRef.current?.scrollBy({ top: -80, behavior: 'smooth' });
};

<Focusable
  onGamepadDirection={handleGamepadDirection}
  style={{ height: 'calc(100vh - 88px)', display: 'flex', flexDirection: 'column' }}
>
  <div ref={scrollRef} style={{ flex: 1, overflowY: 'auto', minHeight: 0, paddingBottom: 48 }}>
    {/* static content */}
  </div>
</Focusable>

Key: Focusable takes the explicit pixel height (NOT flex: 1). The scroll div inside uses flex: 1, overflowY: auto, minHeight: 0.

Interactive content scroll (DropdownItems, buttons)

When the scroll area contains DropdownItems or other interactive Decky elements, Focusable with overflowY: auto gives right-stick gamepad scroll automatically.

CRITICAL: Focusable does NOT forward refs to the DOM node. Never put a ref on Focusable expecting to get an HTMLDivElement -- you get the React component instance and scrollBy/getBoundingClientRect will not work. Instead, nest a plain div inside for the ref:

const scrollContainerRef = useRef<HTMLDivElement>(null);

<Focusable style={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
  <div ref={scrollContainerRef} style={{ height: '100%', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 10, paddingBottom: 48 }}>
    {/* DropdownItems, TextFields, etc. */}
  </div>
</Focusable>

The inner div with overflow: 'hidden' on the parent creates the real scroll container. Steam's input system finds the nearest overflowY: auto ancestor of the focused element and scrolls it -- so it scrolls the inner div correctly.

Bottom padding for Steam status bar

Always add paddingBottom: 48 to the scroll content div. Without it, the last item scrolls behind the Steam MENU/SELECT/BACK bar.


Auto-scroll to Next Question After DropdownItem Selection

Problem

scrollIntoView and bare focus() fight Decky's focus manager -- it scrolls back to the currently-active element. The PanelSection context makes this worse.

Working approach

Use getBoundingClientRect diff to scroll the container directly, plus secondary focus() to move Decky's focus to the next element. Use 300ms delay (not 200ms) to run after Decky's post-dropdown-close focus/scroll sequence finishes:

const questionRefs = useRef<(HTMLDivElement | null)[]>([]);
const scrollContainerRef = useRef<HTMLDivElement>(null);

const scrollToNext = (idx: number) => {
  setTimeout(() => {
    const next = questionRefs.current[idx + 1];
    const container = scrollContainerRef.current;
    if (!next || !container) return;
    const nextTop = next.getBoundingClientRect().top;
    const containerTop = container.getBoundingClientRect().top;
    container.scrollBy({ top: nextTop - containerTop - 20, behavior: 'smooth' });
    next.querySelector<HTMLElement>('button, [role="button"]')?.focus();
  }, 300);
};

Call scrollToNext(idx) directly from the DropdownItem's onChange handler.

Auto-scroll for conditional questions (useRef + direct onChange call)

When questions appear/disappear based on state (e.g. canInstall === 'yes'), use named refs on each conditional wrapper div and trigger scroll from onChange:

const refCanStart  = useRef<HTMLDivElement | null>(null);
const refCanPlay   = useRef<HTMLDivElement | null>(null);

const scrollToRef = (ref: React.RefObject<HTMLDivElement | null>) => {
  setTimeout(() => {
    const el = ref.current;
    const container = scrollContainerRef.current;
    if (!el || !container) return;
    container.scrollBy({ top: el.getBoundingClientRect().top - container.getBoundingClientRect().top - 20, behavior: 'smooth' });
    el.querySelector<HTMLElement>('button, [role="button"]')?.focus();
  }, 300);
};

// In onChange:
onChange={(v) => { setState(v); if (v === 'yes') scrollToRef(refCanStart); }}

The conditional element mounts during the re-render triggered by setState. By the time the 300ms timeout fires, the element is in the DOM and ref.current is valid.

Do NOT use useEffect for this -- it fires at the same time as the render, before state settles in complex scenarios. Direct onChange calls are cleaner and more reliable.


DialogButton Focus Glow for Inline-styled Buttons

DialogButton does not forward onFocus/onBlur. Wrap in a div that captures the bubbled events:

const [focused, setFocused] = useState(false);

<div
  onFocus={() => setFocused(true)}
  onBlur={() => setFocused(false)}
  style={{
    borderRadius: 4,
    border: `1px solid ${focused ? '#7ec8f8' : 'transparent'}`,
    boxShadow: focused ? '0 0 0 2px #7ec8f8, 0 0 8px #3a8fd0' : 'none',
    transition: 'box-shadow 0.15s, border-color 0.15s',
  }}
>
  <DialogButton style={{ ... }}>Label</DialogButton>
</div>

SidebarNavigation Route Values Must Be Full Paths

This one bit hard in v1.7.3 and cost a lot of time. If you use SidebarNavigation from @decky/ui, the route field on each page MUST be a full URL path, not a bare tab name.

What broke

We had routes like this:

pages = [
  { title: 'Manage This Game', identifier: 'manage-game', route: 'manage-game', content: ... },
  { title: 'System Requirements', identifier: 'system-requirements', route: 'system-requirements', ... },
  { title: 'Settings', identifier: 'settings', route: 'settings', ... },
]

When the user clicked a tab, Steam's bundled SidebarNavigation pushed /routes/<route> to the router. So clicking Settings sent Steam to /routes/settings, which is Steam's actual Settings page. Clicking other tabs went to /routes/manage, /routes/logs, etc, which are unregistered routes that render as a blank screen.

disableRouteReporting={true} does NOT prevent this push in current Steam builds. Most likely a recent Steam client update changed how that flag is honored (@decky/ui only locates the component inside Steam's JS bundle by prop name patterns, so the actual logic lives in Steam itself and can change without our deps moving).

The fix

Mirror what Decky Loader's own Settings sidebar does. Use full paths that are subpaths of your registered route:

routerHook.addRoute('/proton-pulse', ProtonPulsePage);  // unchanged

pages = [
  { title: 'Manage This Game', identifier: 'manage-game', route: '/proton-pulse/manage-game', ... },
  { title: 'System Requirements', identifier: 'system-requirements', route: '/proton-pulse/system-requirements', ... },
  { title: 'Settings', identifier: 'settings', route: '/proton-pulse/settings', ... },
]

React Router 5 prefix-matches by default, so /proton-pulse/<anything> still resolves to the /proton-pulse route component. The URL push happens but our component stays mounted, no remount, no state loss, no blank screen.

SidebarNavigation will give you the full route back in onPageRequested(page), so convert at the boundary if your state stores short tab names:

const ROUTE_PREFIX = '/proton-pulse';
const tabToRoute = (tab: string) => `${ROUTE_PREFIX}/${tab}`;
const routeToTab = (route: string) =>
  route.startsWith(`${ROUTE_PREFIX}/`) ? route.slice(ROUTE_PREFIX.length + 1) : route;

<SidebarNavigation
  pages={pages}
  page={tabToRoute(activePage)}                    // 'manage' -> '/proton-pulse/manage'
  onPageRequested={(p) => setActivePage(routeToTab(p))}  // '/proton-pulse/manage' -> 'manage'
  disableRouteReporting={true}
/>

Reference

  • Decky Loader's own settings uses this exact pattern: frontend/src/components/settings/index.tsx registers /decky/settings once, then each sidebar page has route: '/decky/settings/general', '/decky/settings/plugins', etc.
  • Confirmed working with @decky/ui@4.11.0 and Steam Client builds as of 2026-05.

How to know this is happening to you

Symptoms:

  • Clicking a sidebar tab shows a blank screen
  • Clicking Settings opens Steam's actual Settings page
  • The plugin component remounts on every tab switch (state resets, mount/unmount logs fire)
  • globalThis.location.pathname shows /routes/<tab-route-name> instead of /routes/<your-plugin>

Don't waste time on pushState/replaceState hooks or Router.Navigate patches. Just use full paths. The polling/intercept workarounds caused screen flashes and never fully fixed the underlying remount.

Good Defaults for Future Work

  • Build future major screens as one flat content surface first.
  • Add decorative panel styling only if the information hierarchy truly needs it.
  • Assume Steam focus will do the wrong thing unless explicitly guided.
  • Test horizontal toolbars on the real Deck early.
  • Use logs to confirm whether a "scroll problem" is actually a sizing/layout problem.
  • Keep a screenshot history during iteration. The Deck reveals issues that are easy to miss in source or on desktop.

Clone this wiki locally