- docs/inventory/*.md: every surface, route, sub-menu and state with reproducible --pre snippets, tokens, Steam-owned pseudo/transforms, risks - lab: exact surface targeting (bar vs barpopup), locked js, mark/restore closes menus/dialogs/popups a step opened, MSYS route unmangling, UTF-8 - tokens: high-contrast mode goes opaque - docs/LAB.md: gotchas (animated focus fills need !important, keyboard focus class, clipped popups, multiple page backgrounds)
54 KiB
Inventory: area shell (surface main: the window chrome around every route)
Mapped live on the Frame on 2026-10-06 with the theme off. Every shot is 1.5x (1920x1080) and lives at shots/shell_*_before.png. Tokens are copied from python glass.py outline. Steam's CSS is quoted from the live stylesheets of the main document, with hashes replaced by tokens. The client is Steam build 11041156 (the constant every webpack module carries) on Chrome 126.
Scope: the root layers of the main window, the page background, the header (Back and search), the search route, the in-window footer legend, the modal layer (scrim and dialogs), the generic gamepad context menu (including the power menu), page transitions, focus rendering and global CSS variables. The VR main menu is drawn in frame.menu, and bar.md §3 maps it in detail. This file covers only how the main menu is opened.
0. How to reproduce (read first)
0.1 Driver notes
- Git Bash rewrites routes. MSYS turns
--route /library/homeintoC:/Program Files/Git/library/home. Steam then navigates relative to the current route and lands on junk such as/library/tab/C:/Program Files/Git/apprunning. Run every command withexport MSYS_NO_PATHCONV=1. Navigating inside--pre(L.nav('/x')) is unaffected. - Windows console encoding. Output that contains smart quotes crashes
glass.pywhile it prints, before the PNG is downloaded. Setexport PYTHONIOENCODING=utf-8. - Other agents leave menus open. Modals and context menus live in the
mainwindow, whatever the route, so another agent's open menu shows up in your shot. Every--prebelow starts by waiting for the modal stack to be empty:const MM=SteamUIStore.WindowStore.VRGamepadUIMainWindowInstance.ModalManager; for(let i=0;i<40&&MM.m_rgModals.length;i++) await new Promise(r=>setTimeout(r,250)); - Multi-line
--pre. Write the JS to a file and pass--pre "$(cat file.js)". All snippets below were run that way. - Synthetic, side-effect-free dialogs and menus. These are rendered with Steam's own components through webpack, so they use exactly the production DOM and CSS:
Each snippet closes what it opened with a
let req; webpackChunksteamui.push([[Symbol()],{},r=>req=r]); // module ids (build 11041156; if they move, search req.m sources for the loc token / export) // 62540 jsx runtime (J.jsx) 30583 ConfirmModal = .o0 // 12302 showModal = .pg(el, window) 12711 Menu .tz, MenuItem .kt, SubMenu .Vs, CheckItem .IK, Separator .K5 // 377 showContextMenu = .lX(menuEl, anchorEl, {}) 79100 power menu = .d4(anchorEl, onCancel, false) // 4399 universal search text store = .U (SetSearchText / GetSearchText)setTimeoutthat fires about 1 s after the capture, while the lock is still held or just released. Modals close withinst.Close()orMM.RemoveModal(entry), which is exactly what Close does. Menus close withinst.Hide(). The modal count was verified to be 0 after every run.
0.2 Snippets (all tested verbatim; WAIT = the wait line above)
| id | --pre body |
|---|---|
| HOME | (async()=>{ WAIT L.nav('/library/home'); await new Promise(r=>setTimeout(r,1500)); return L.route()})() |
| FOOTER | the same with /library/tab/AllGames (route with 5 legends) |
| TITLE | (async()=>{L.nav('/controller/calibration/0'); await new Promise(r=>setTimeout(r,1800)); return L.outline('main',{sel:'#header',max:20})})() |
| SEARCH(q, tab) | (async()=>{ WAIT let req; webpackChunksteamui.push([[Symbol()],{},r=>req=r]); const U=req(4399).U; U.SetSearchText(q); L.nav('/search/tab/'+tab); await new Promise(r=>setTimeout(r,2500)); setTimeout(()=>U.SetSearchText(''),4500); return L.route()})(). Used q='half' with tab All, then '', 'zzqxjvkw', 'a' (Friends) and 'half' (Store). For VIEW MORE add const sc=L.q('main','%{TabContentsScroll}'); sc.scrollTop=sc.scrollHeight; and wait 900 ms |
| CONFIRM | (async()=>{ WAIT L.nav('/library/home'); await new Promise(r=>setTimeout(r,1200)); let req; webpackChunksteamui.push([[Symbol()],{},r=>req=r]); const J=req(62540),D=req(30583),M=req(12302); const inst=await M.pg(J.jsx(D.o0,{strTitle:'Glass Shell lab dialog',strDescription:'Mapping the dialog chrome. This dialog performs no action.',strOKButtonText:'OK',strCancelButtonText:'Cancel',onOK:()=>{},onCancel:()=>{}}), L.surface('main')); setTimeout(()=>inst.Close(),4500); await new Promise(r=>setTimeout(r,800)); return 1})() |
| ALERT | CONFIRM with strOKButtonText:'Close', bAlertDialog:true and no Cancel text |
| MENU | (async()=>{ WAIT L.nav('/library/home'); await new Promise(r=>setTimeout(r,1200)); let req; webpackChunksteamui.push([[Symbol()],{},r=>req=r]); const J=req(62540),Mn=req(12711),CM=req(377),n=()=>{}; const items=[J.jsx(Mn.kt,{onSelected:n,children:'Lab item (focused)'},'a'),J.jsx(Mn.kt,{onSelected:n,selected:true,children:'Selected item'},'b'),J.jsx(Mn.Vs,{label:'Submenu',children:[J.jsx(Mn.kt,{onSelected:n,children:'Sub item one'},'s1'),J.jsx(Mn.kt,{onSelected:n,children:'Sub item two'},'s2')]},'sub'),J.jsx(Mn.IK,{onSelected:n,bChecked:true,children:'Checked item'},'c'),J.jsx(Mn.K5,{},'sep'),J.jsx(Mn.kt,{onSelected:n,tone:'positive',children:'Positive tone'},'p'),J.jsx(Mn.kt,{onSelected:n,tone:'emphasis',children:'Emphasis tone'},'e'),J.jsx(Mn.kt,{onSelected:n,tone:'destructive',children:'Destructive tone'},'d'),J.jsx(Mn.kt,{onSelected:n,disabled:true,children:'Disabled item'},'x')]; const inst=CM.lX(J.jsx(Mn.tz,{label:'Lab context menu',children:items}), L.q('main','%{SearchAndTitleContainer}'), {}); setTimeout(()=>inst.Hide(),4500); await new Promise(r=>setTimeout(r,1100)); return 1})() |
| SUBMENU | MENU, then L.click('main','%{*BasicContextMenuModal>SubMenu}'); await new Promise(r=>setTimeout(r,700));, with the close at 5500 ms |
| POWER | (async()=>{ WAIT let req; webpackChunksteamui.push([[Symbol()],{},r=>req=r]); const before=MM.m_rgModals.length; const inst=req(79100).d4(L.surface('main').document.documentElement,()=>{},false); setTimeout(()=>{try{inst.Hide()}catch(e){} for(const m of MM.m_rgModals.slice(before).reverse()) MM.RemoveModal(m)},3800); await new Promise(r=>setTimeout(r,900)); return 1})(), run with --settle 0.8. Look only. Every item is a power or session action. |
| ZOO(label) | (async()=>{ WAIT L.nav('/zoo/modals'); await new Promise(r=>setTimeout(r,1600)); const w=L.surface('main'),d=w.document; const b=[...d.querySelectorAll('button,.DialogButton,[role=button]')].find(e=>e.textContent.trim()===LABEL); b.scrollIntoView({block:'nearest'}); const r=b.getBoundingClientRect(),o={bubbles:true,cancelable:true,view:w,clientX:r.x+r.width/2,clientY:r.y+r.height/2,button:0}; const before=MM.m_rgModals.length; for(const t of ['pointerdown','mousedown','pointerup','mouseup','click']){const E=t.startsWith('pointer')?w.PointerEvent:w.MouseEvent; b.dispatchEvent(new E(t,Object.assign({pointerType:'mouse',isPrimary:true},o)))} await new Promise(r=>setTimeout(r,900)); const added=MM.m_rgModals.slice(before); setTimeout(()=>{for(const m of added.reverse()) MM.RemoveModal(m)},4000); return added.length})(). /zoo is Steam's own component zoo: demo modals with no-op handlers |
0.3 Before-shots
| file | surface | how | shows |
|---|---|---|---|
shell_library_home_before.png |
main | HOME | window chrome with header (%{OverrideHeaderBackground}, bg opacity 1), page gradient, no footer (Home hides it) |
shell_footer_legend_before.png |
main | FOOTER | footer legend with 5 actions; header bg opacity 0, because the tab row acts as the header background |
shell_header_title_before.png |
main | TITLE | header in title mode (%{ShowingTitle}), an empty page over the bare window gradient, footer with Back only |
shell_search_results_before.png |
main | SEARCH('half','All') | search route: grey filled search field, result grid |
shell_search_empty_before.png |
main | SEARCH('','All') | search route with empty query (whole library listed), placeholder in the filled field |
shell_search_noresults_before.png |
main | SEARCH('zzqxjvkw','All') | %{NoResultsFound} |
shell_search_friends_before.png |
main | SEARCH('a','Friends') | friend result tiles (profile background + avatar) |
shell_search_store_before.png |
main | SEARCH('half','Store') | store result tiles |
shell_search_viewmore_before.png |
main | SEARCH('half','All') + scroll to bottom | "View more in the Store" tile; pinned tab row with its ::before backdrop |
shell_modal_confirm_before.png |
main | CONFIRM | scrim + GenericConfirmDialog (Primary focused) |
shell_modal_alert_before.png |
main | ALERT | single full-width Primary button |
shell_modal_textprompt_before.png |
main | ZOO('Text Prompt') | dialog with focused text input (white) |
shell_modal_scrollpanel_before.png |
main | ZOO('Scroll Panel Test') | full-size scrolling dialog plus the %{FocusRing} outline |
shell_modal_noninteractive_before.png |
main | ZOO('Non-Interactive Dialog') | dialog with no buttons; ModalPosition itself takes .gpfocus |
shell_contextmenu_before.png |
main | MENU | context menu with every item state |
shell_contextmenu_submenu_before.png |
main | SUBMENU | submenu open: parent %{*BasicContextMenuModal>active}, sibling %{*BasicContextMenuModal>Selected} |
shell_contextmenu_long_before.png |
main | ZOO('Longboi (long title)') | long title and long list |
shell_powermenu_before.png |
main | POWER | real power menu: Destructive items, red focused item |
shell_vr_mainmenu_before.png / shell_vr_mainmenu_collapsed_before.png |
frame.menu | shot frame.menu NAME --theme off |
VR main menu expanded and collapsed (see bar.md §3) |
shell_zoo_modals.png |
main | L.nav('/zoo/modals') |
the zoo's modal test page (test bench, not product UI) |
1. Root layer stack of main
Live DOM, with the CSS that paints each layer today:
html.%{SteamUIPopupHTML} no background (transparent overlay)
body.%{*PopupBody} .GamepadMode .BasicUI .%{SteamUIPopupWindowBody} .%{GamepadUIPopupWindowBody} .LowPerfMode .WindowFocus
│ overflow:hidden; user-select:none; ::-webkit-scrollbar {display:none} for every descendant; no bg
└ div#popup_target.%{SteamUIPopupWindow} 100%x100%, no paint
└ div.%{Root}.noOpinionatedGlobalStyles 0x0 wrapper
└ div[data-accent-color=blue][data-dull-color=greyneutral][data-body-text-color=text-light]
style="--color-success-1..12, --color-warning-*, --color-error-*, --color-text-success-*…" (inline; see §10)
└ div.BasicUI.GamepadMode.%{BasicUiRoot}.%{SteamUIPopupHTML>VR}.MediumWindow.WideWindow
style="--basicui-header-height: 40px; --gamepadui-current-footer-height: 42px;" ← inline, Steam-owned
position:absolute; z-index:5; overflow:hidden; **border-radius:6px** (the window's rounded corners); no bg
└ div#MainNavMenu-Rest.%{MainNavMenuMainSplit} flex column, 100% height, no paint
├ div#GamepadUI_VR_Full_Root.%{BasicHome}.%{OpaqueBackground} ← THE WINDOW BACKGROUND (§2); position:relative; flex column
│ ├ div.%{GamepadDialogOverlay}.GamepadMode.FullModalOverlay modal layer (§6). style="display:none" when idle
│ ├ div#header.%{Profile>Header} … header bar (§3), abs, z 6000
│ ├ div.%{FocusRingRoot} 0x0, abs, z 10000, pointer-events none (§9)
│ └ div#Main.%{PopupBody>Content}.Panel.Focusable route content (flex:1)
│ └ div.%{PartnerEventOverlayContainer>AppDetailsMain} position:relative; opacity transitions (§8)
│ ├ div.%{TopLevelTransitionSwitch} abs inset 0
│ │ └ div.%{ContentWrapper}.%{TopLevelTransition}[.%{NoTransitionZoom}][.%{TopLevelTransitionSwitch>Enter}.%{…>EnterActive}]
│ │ └ div.%{AbsoluteDiv} abs inset 0, flex column
│ │ └ <page root> e.g. %{GamepadLibrary}, %{GamepadSearch}, %{OverflowHidden>GamepadPage}%{DialogBackground}, .BasicUI %{DownloadsPage}
│ └ div.%{PartnerEventOverlayContainer} abs inset 0, z 10, pointer-events none
│ └ div.%{Container>TransitionWrapper} app-details scrim: bg rgba(16,16,16,.75) (LowPerfMode), opacity 0 idle (§8)
└ div#Footer.%{BasicFooter} footer legend (§5), abs bottom, z 7000. Absent when hidden
Notes:
- Body classes are state.
LowPerfModeis always on for the Frame and switches some rules, for example the TransitionWrapper colour.WindowFocustoggles with window focus.MediumWindow WideWindowon%{BasicUiRoot}are size breakpoints. None of these may be removed or overridden. - Outside
%{BasicUiRoot}nothing paints, so the corners outside the 6px radius are transparent and show passthrough or the game. - In VR there are no side-menu siblings in
%{MainNavMenuMainSplit}. In desktop gamepad mode,P9would render the main menu and Quick Access side panels here. In VR,P9instead portalsVRMainNavMenuContainerinto theframe.menupopup (§4). MouseHoverBlockerHack: when the nav source is not the mouse, Steam can insertdiv#MouseHoverBlockerHack(1x1, inlineposition:absolute; pointer-events:all) inside an inlinecontain:layoutwrapper. It was not present during mapping. Never style it.
2. Window background (what is behind every page)
- The base layer is
%{BasicHome}. It has exactly one of three variants, chosen by route in source module 46307 (F9):%{BasicHome}%{OpaqueBackground}is the default for all normal routes. It paintsbackground: radial-gradient(155.42% 100% at 0% 0%, #060a0e 0, #060a0e 0%, #0e141b 100%). Under@media (prefers-contrast: more)it paints#000.%{BasicHome}%{PopupBody>TransparentBackground}paintsrgba(0,0,0,0). It is used on overlay paths:/apprunning,/keyboard,/app/:id/overlay,/gameapiosk, controller mouse-position,/colorsettings,/steamweb,/externalweband/microtxnauth(SteamUIStore.BIsTransparentBackgroundPath).%{BasicHome}%{TrueBlackBackground}paints#000. It is used on/app/:id/controllerconfigurator(except Choose Configuration),/colorsettingsand/chat.%{BasicUiRoot}%{PopupBody>BlackBackground}paints#000. It was not seen.
- Page roots repaint the same gradient. Clearing the
%{BasicHome}gradient alone will not make the window glass, because these page roots paint over it:%{GamepadLibrary}and%{GamepadSearch}paint the same radial gradient #060a0e→#0e141b..BasicUI %{DownloadsPage}and%{OverflowHidden>GamepadPage}%{DialogBackground}paint the same gradient. The second is the root of settings-style paged pages and/zoo.%{ContentManagement}:not(%{InPagedSettings})paints a gradient from #23262e to #0e141b.%{PagedSettingsDialog_PageList_ShowTitle>PagedSettingsDialog}paints solid #0e141b, and other solid #0e141b page surfaces exist. The library, settings and downloads areas own these.
- Background art. The shell draws none. Game pages and Home draw their own hero art inside the page (library and game areas).
%{GamepadSearch}also setspadding-top: var(--basicui-header-height)so its content clears the header.
3. Header bar #header (Back and search, or title)
Source: module 62678. In VR the header component is pe (memo) wrapped by Xt.
3.1 Structure (outline main --sel '#header')
div#header.%{Profile>Header}.GamepadMode[.%{OverrideHeaderBackground}][.%{HeaderOpaque}][.%{SuppressInteraction}][.%{FadeBackgroundOpacity}].FlexGrowUniversalSearch.Panel.Focusable [0,0 1280x40]
style="--gamepadui-header-opacity: 1; --gamepadui-header-background-opacity: 1|0;" ← inline, Steam-owned state
├ div.%{OverridesInteractionSuppression}.%{BackContainer} [0,0 108x40] Back (mouse/laser only; NOT .Focusable)
│ ├ svg.%{IbexDiagramFrontPanelTransparencyEffect>FlipInRTL}.%{ArrowBack} 24x24 at x 20
│ └ span.%{BackContainer>BackButton} "Back"
└ div.%{SearchAndTitleContainer}.%{ShowingSearch}.%{ForceExpanded}.%{SearchAndTitleContainer>VR}.Panel.Focusable [108,0 1172x40]
├ div.%{SearchFieldBackground}[.%{WhiteBackground}] abs inset 0: the field fill
├ svg.%{SearchIconLeft}[.%{WhiteBackground}] 18x18
├ input[type=search].%{SearchBox}.%{SearchAndTitleContainer>Visible}[.%{WhiteBackground}].Focusable placeholder "Search for games or profiles..."
└ svg.%{SearchIconRight}[.%{WhiteBackground}] opacity 0 while expanded
Title mode instead: div.%{SearchAndTitleContainer}.%{ShowingTitle}.%{ForceExpanded}.%{SearchAndTitleContainer>VR}
└ div (no class) "Calibration & Advanced Settings" select it as `%{SearchAndTitleContainer}%{ShowingTitle} > div`
Browser mode (a Steam web view is current): div.%{HeaderBrowser} with the URL bar replaces the search, and the Back container is removed
Account alert (only with active support alerts): div#header_profile.%{HeaderItem}.%{Profile>Clickable} with %{Profile>HasActiveSupportAlert}, %{Profile>CurrentUserAvatar}
3.2 What paints, today
| element | paint | source rule |
|---|---|---|
%{Profile>Header} |
no own background. opacity: var(--gamepadui-header-opacity), z-index: 6000, pointer-events: none (children initial), transform-style: preserve-3d, text #fff |
|
%{Profile>Header}::before (Steam-owned) |
content:""; position:absolute; inset 0 (height 100%); background-color: rgba(0,0,0,.5); backdrop-filter: blur(100px); z-index:-1; opacity: var(--gamepadui-header-background-opacity). Under prefers-contrast it is #000, opacity 1 |
this is the header bar |
.%{OverrideHeaderBackground}.gpfocuswithin::before |
same values, with transition-delay: 0s |
routes /library/home, /apprunning, /colorsettings, /login, /createaccount, /account, /settings*, /media/item |
.%{FadeBackgroundOpacity}::before |
transition: background-color .1s .1s, opacity .2s |
|
.%{HeaderOpaque} |
background-color: #000; backdrop-filter: none |
/apprunning, /steamweb, /externalweb, /microtxnauth. In VR the header is not rendered on /apprunning |
.%{InQuickAccess} |
background: #0e141b; position: initial |
not used in VR |
%{BackContainer} |
colour #8b929a, :hover #fff, no bg |
|
%{BackContainer}:not(:last-child)::after (Steam-owned) |
1px x 2em divider, background: #23262e, right: 0 |
|
%{BackContainer>BackButton} |
padding 0 20px 0 8px, 16px Motiva | |
%{SearchAndTitleContainer} |
background: transparent, overflow: hidden, transition: background-color 50ms, weight 500, colour #0e141b while ForceExpanded. The non-VR :hover rgba(255,255,255,.3) is excluded in VR |
|
%{SearchFieldBackground} |
transparent when idle. With %{WhiteBackground}: #fff, or #b8bcbf when %{ForceExpanded}:not(.gpfocuswithin) (the grey field in the search shots). transform: scaleX(0→1) with transform-origin: 101% 50% is Steam-animated |
|
%{SearchIconLeft} |
VR colour #8b929a. translateX(20px→5px) and opacity are animated. With %{WhiteBackground} it is #000 |
|
%{SearchIconRight} |
opacity 0 and translateX(-18px) while expanded (always, in VR) |
|
%{SearchBox} (input) |
transparent, no border, 16px. Text #0e141b, caret #000 while ForceExpanded. ::placeholder #8b929a italic, with opacity and translateX animated |
|
input::-webkit-search-cancel-button |
the clear (x) button: 26px black circle with a white X svg, :hover #3d4450, :active #23262e |
shown when text is present |
| VR hover | .%{ShowingSearch}.%{SearchAndTitleContainer>VR}:hover makes the svgs and placeholder #fff, unless %{WhiteBackground} |
|
| Title text | inherits #fff and weight 500. .FlexGrowUniversalSearch .%{ShowingTitle} sets padding-left: 2.8vw |
|
%{HeaderItem} (alert/avatar) |
padding 0 15px (VR), #fff. .gpfocus / .gpfocuswithin: bg #fff, colour #000. %{Profile>Clickable}:hover rgba(255,255,255,.3) |
3.3 States and when they occur
- Search idle: you are not on
/searchand there is no text. The field is transparent: icon, then placeholder, on the header glass. Seen inshell_library_home_before. - Search filled (
%{WhiteBackground}): applies when focused, when text is present, or on any/search…route. Unfocused it paints the grey #b8bcbf field with dark text. Seen inshell_search_*. - Search focused: the gamepad focus is in the field (
.gpfocuswithinon the container,.gpfocuson the input). It paints a #fff field with dark text. Not captured: focusing callsonKeyboardShow(it navigates to/search/tab/Alland raises the VR keyboard surface). The state comes from CSS. - Header background opacity: forced to 1 in VR, except on routes whose tabbed page is the header background (
m_nNumTabbedPagesActingAsHeaderBackground>0, e.g./library/tab/*,/search/*). There the inline var is 0, the::beforeis invisible, and the page's pinned tab row (%{TabHeaderRowWrapper}with its own::before) supplies the backdrop. Compareshell_footer_legend_beforewithshell_library_home_before. - Header hidden (not rendered): in VR on
/apprunning,/keyboard,/gameapiosk,/oobe*, on the lock screen and during Frame tutorial videos (Trlist), unlessHeaderStore.BShowHeader()forces it on. - Title mode: pages that call
useHeaderTitle. In VR these are the controller calibration, controller device support, controller configurator choose-configuration and captive portal pages. Example:/controller/calibration/0. - Modal open: the header stays above the modal scrim (z 6000 against the overlay's z 1500), so it is never dimmed. A modal's
ModalPositionstarts attop: var(--basicui-header-height).
3.4 Do not touch
The inline --gamepadui-header-opacity and --gamepadui-header-background-opacity (scroll and route driven). opacity on #header. The transforms on %{SearchFieldBackground}, the search icons and ::placeholder. overflow: hidden on %{SearchAndTitleContainer}. pointer-events on the header. To restyle the bar itself, restyle %{Profile>Header}::before and keep its opacity: var(--gamepadui-header-background-opacity).
4. Main menu and side menus in VR
- There are no side menus inside
mainin VR.MenuStore.OpenSideMenu(e)returns immediately whenIsAnyVRWindow().m_eOpenSideMenustays 0. - Main menu:
MenuStore.OpenMainMenu()callsVRFrameStore.frames.find(...).FocusLeftFrameMenu(). The main menu isVRMainNavMenuContainer, portaled by the main window into theframe.menu.<id>popup to the left of the window (origin_on_parent {x:-1},only_visible_with_laser).- Tokens:
%{PopupRoot} > %{PopupContent}%{PopupBody>AlignRight}%{AlignCenterY} > div > %{DashboardMenu}%{Variant_FrameMenu}[%{DashboardMenu>Collapsed}] > %{DashboardMenu>ItemOuter} > %{DashboardMenu>Item}[%{DashboardMenu>Active}] > %{DashboardMenu>ItemIcon} + %{DashboardMenu>ItemLabel} > %{Marquee>Container} > %{Marquee>Content}, with%{SectionGap}before Power. - Items: Home, Library, Store, Friends & Chat, Media, Downloads, Steam Settings, VR Settings, gap, Power.
- Paint:
--menu-background: #0e141b(a custom property on%{DashboardMenu}, which is a good lever). Radius 6 on the first and last item of each group. Inactive text and icons #8b929a. Active itemscale(1.1) rotateX(1deg)with a blue #1a9fff%{DashboardMenu>ActiveDot}(inlinewidth:10px;height:50%)..gpfocusruns a fill-forwards background animation (§9). - Collapsed (54px wide, labels
display:none) until laser hover or focus. Seeshell_vr_mainmenu_collapsed_before. - Full detail, including the hover-expand snippet, is in
bar.md§3. That agent owns theframe.menusurface.
- Tokens:
- Power menu: the Power item calls
SteamUIStore.OpenPowerMenu(el), which calls79100.d4, which callsshowContextMenu. It therefore renders inmainas a standard gamepad context menu (§7):- header "Power"
- items Sleep, Shutdown (Destructive), Restart Device (Destructive), Restart Steam VR (Destructive, preferred focus)
- separator
- Change Account, Sign Out, Restart Steam (all Destructive)
- separator, Cancel
- See
shell_powermenu_before. Selecting an item opens a confirm modal. Never select.
- Quick Access (
OpenQuickAccessMenu/ToggleSideMenu(QuickAccess)) is abarpopuppanel in VR. Seebar.md§2.4. - B button at the root:
onCancelButtonon#GamepadUI_VR_Full_RootcallsOpenMainMenu, which focuses the frame menu. - When a modal opens in the VR main window, Steam closes every dashboard-bar popup (
VRDashboardBarPopups.forEach(p => p.closePopup()), module 46307W9).
5. Footer legend #Footer (in-window button legend)
div#Footer.%{BasicFooter}[.%{WithKeyboard}][.%{QuickAccessFooter}][.%{PopupBody>Opaque}][.%{Relative}] [0,678 1280x42]
└ div.%{FooterLegend} flex-wrap, justify-content flex-end, overflow hidden
└ div.%{ActionButtonLegend} ×N clickable (dispatches the action), padding 5px 8px, r 6
├ div.%{ActionButtonLegend>ActionButtonGlyph}
│ └ img.%{FooterGlyphSize} aria="A Button"|"B Button"|"X Button"|"Y Button"|"Menu Button" (Menu also has %{ControlsListSection>PillShapedIcon}) 25px tall raster glyphs
└ div.%{ActionButtonLabel} "Select" | "Back" | "Options" | "Sort By" | div.%{CompatFooterDescription} "Filter" …
| element | paint today |
|---|---|
%{BasicFooter} |
background-color: rgba(0,0,0,.5); backdrop-filter: blur(100px). Top hairline via border-top: 1px solid plus border-image: linear-gradient(90deg, transparent, rgba(255,255,255,.05) 25%…75%, transparent) 1 0 0. Padding 0 calc(4px + 1.4vw). z-index 7000, abs bottom. Prefers-contrast: #000. %{PopupBody>Opaque}: #000, no blur |
%{ActionButtonLegend} |
transparent, r 6. :hover rgba(255,255,255,.1) with a 150 ms transition |
%{ActionButtonLabel} |
12px bold uppercase, letter-spacing .5px, #fff |
%{FloatingVRFooter} variant |
used by the separate floatingfooter surface (location:"vr-floating"). No bg, filter: brightness(.6) drop-shadow(black 0 1px 0) |
- When it shows:
FooterStore.BShowFooter(). Home callsHideFooter(count 1), so there is no footer on/library/home. Other routes show the legend for the focused element's actions. - Modals add
%{*GamepadDialogContent>FooterVisible}toModalPositionso dialogs end above the footer (bottom: var(--gamepadui-current-footer-height)). - The footer stays above modal scrims (z 7000).
- No pseudo-elements. Glyph
imgs are rasters, so a theme must not recolour them with filters that kill legibility.
6. Modal layer (scrim + dialogs)
Source: ModalManager on VRGamepadUIMainWindowInstance (SetUsePopups(false), so every dialog renders in-window). The wrapper is module 46307 W9 (t9.EO, className %{GamepadDialogOverlay} GamepadMode). The VR position wrapper is module 7852 I.
6.1 Structure
div.%{GamepadDialogOverlay}.GamepadMode.FullModalOverlay abs inset 0, z 1500. Idle: inline style="display:none" (Steam toggles it)
├ div.ModalOverlayContent.ModalOverlayBackground ← SCRIM: background rgba(0,0,0,.85); backdrop-filter: blur(3px); abs inset 0; z 7
└ div.ModalOverlayContent.active abs inset 0, z 10 (.inactive = z 5 for a lower stacked modal)
└ div.%{*GamepadDialogContent>ModalPosition}[.%{*…>WithStandardPadding}][.%{*…>VR}][.%{*…>FooterVisible}][.%{*…>ScrollWithin}][.%{*…>NoHeaderPadding}].Panel.Focusable
position:absolute; top: var(--basicui-header-height); bottom: var(--gamepadui-current-footer-height) (0 in VR without FooterVisible);
overflow: hidden auto (SCROLLS); flex column; WithStandardPadding: padding 24px; .gpfocus outline none
├ div.%{*GamepadDialogContent>ModalClickToDismiss} abs inset 0 (click-outside = cancel). Transparent; keep it
└ <content> one of:
A) dialog: div.%{*GamepadDialogContent_InnerWidth>GamepadDialogContent}.DialogContent._DialogLayout.GenericDialogBase.GenericConfirmDialog
└ div.%{*GamepadDialogContent>GamepadDialogContent_InnerWidth}.DialogContent_InnerWidth
└ form[role=dialog]
├ div.DialogHeader[role=heading]
├ div.DialogBody.Panel
│ ├ div.DialogBodyText
│ └ div.DialogFooter > div.DialogTwoColLayout._DialogColLayout > button.DialogButton._DialogLayout.Primary|Secondary.%{*GamepadDialogContent>Button}
└ (text prompt) div.DialogInput_Wrapper._DialogLayout > input.%{*GamepadDialogContent>BasicTextInput}.DialogInput.DialogInputPlaceholder.DialogTextInputBase
B) context menu: div.BasicUIContextMenu.%{*BasicContextMenuHeader>BasicContextMenuModal} … (§7)
C) custom content (zoo Scroll Panel): div.Panel > div.%{ScrollPanel}.%{ScrollY} (inline flex:1; padding:12px) + DialogButton; with its own %{FocusRingRoot}/%{FocusRing}
6.2 Paint today
| element | paint |
|---|---|
scrim .ModalOverlayBackground (in %{GamepadDialogOverlay}) |
background: rgba(0,0,0,.85); backdrop-filter: blur(3px). The global default is .8 without blur. |
ModalPosition > %{*…GamepadDialogContent} (the dialog card) |
background-color: #0e141b; border: 2px solid #23262e; padding: calc(12px + 2.25vh) 2.8vw; width: 660px; margin: auto, no radius, no shadow. animation: <fade> .5s cubic-bezier(.16,.86,.43,.99) both, where the keyframes animate opacity 0→1 and transform: scale(1). .DeckVerifiedModalDialog .DialogContent forces #0e141b !important |
.DialogHeader |
22px/28px bold #fff, margin-bottom 10px |
.DialogBodyText |
16px/20px #dcdedf, margin-bottom 10px |
.DialogSubHeader / .DialogControlsSectionHeader (BasicUI) |
16px 600 uppercase, rgba(255,255,255,.5); the section header has a 1px #23262e bottom border |
button.DialogButton.%{…>Button} base |
padding 10px 24px, min-width 160px, r 2px, #dcdedf, bg rgba(255,255,255,.15) (Primary too, when unfocused) |
:hover |
bg rgba(255,255,255,.2). Primary:hover: rgba(26,160,255,.486) |
.gpfocus / :active |
bg #fff, text #23262e. .Primary.gpfocus: #1a9fff / #fff. .Destructive.gpfocus: #de3618 / #fff. .BarButton.gpfocus: #fff / #000. .Glyph.gpfocus: filter: brightness(30%) |
.Disabled / [disabled] |
opacity .4, bg #131418, pointer-events: none (focused: bg #000) |
%{…ActiveAndUnfocused} |
bg #0e141b |
::before on DialogButton |
Steam sets content: none, so it is free for a theme |
%{*GamepadDialogContent>BasicTextInput} |
.BasicUI bg rgba(255,255,255,.1), text #8b929a, padding 10px 16px, r 0. .gpfocus: bg #fff, text #000. Placeholder italic #67707b |
._DialogInputContainer.gpfocus / .Active |
#fff / #0e141b |
6.3 Variants seen
| variant | notes |
|---|---|
| confirm (OK/Cancel) | shell_modal_confirm_before. bDestructiveWarning:true has no visual effect in gamepad dialogs (checked live, so no separate shot) |
| alert | one full-width Primary button |
| text prompt | Header + input only, no buttons |
| scroll panel (large custom) | the content scrolls inside %{ScrollPanel}. This is where %{FocusRing} appears (§9) |
| non-interactive | no focusables, so ModalPosition itself carries .gpfocus |
| empty dialog | zoo "Empty Dialog", same chrome |
- Stacking: a dialog opened while a context menu is open replaces the menu (the menu hides), so
.ModalOverlayContent.inactivewas not observed. Its CSS isz-index: 5. - Header and footer are not covered by the scrim. Plan glass for header and footer over a dimmed page.
- Never touch:
displayon%{GamepadDialogOverlay}(inline toggle); the position, top or bottom ofModalPosition(they use the layout vars); the dialog-card animation, which keepstransformanimated (use the independentscaleproperty if needed);ModalClickToDismiss.
7. Generic context menu (gamepad presentation)
Context menus in main (power menu, game Options menus, sort/filter pickers, dropdown lists, zoo menus) all render as a centred modal sheet. Tokens come from the gamepaddialog CSS module, which ships 4 builds, so always use the %{*…} forms. For any class name N, the tokens *BasicContextMenuModal>N and *GamepadDialogContent>N (written inside %{…}) expand to the same class set. The outline prints the first form and the CSS dump prints the second.
div.%{*GamepadDialogContent>ModalPosition}.%{*…>VR}[.%{*…>FooterVisible}] (no WithStandardPadding)
├ div.%{*…>ModalClickToDismiss}
└ div.BasicUIContextMenu.%{*BasicContextMenuHeader>BasicContextMenuModal} abs inset 0; flex column, centred
├ div.%{*BasicContextMenuModal>BasicContextMenuHeader} "Power" | menu label 18px/22px #dcdedf centred; :empty hidden
├ (div.%{*BasicContextMenuHeaderShrinkableSpacing})
└ div.%{*BasicContextMenuModal>BasicContextMenuContainer}.Panel max-width 70%, overflow hidden; ANIMATION (opacity 0→1, transform scale(1)) .5s both
├ div.%{*BasicContextMenuModal>contextMenuContents}[.%{*…>hasSubMenu}].Panel[role=menu]
│ width fit-content; max-height 100%; overflow: hidden auto (SCROLLS); scroll-padding 72px 0;
│ **filter: drop-shadow(rgba(0,0,0,.5) 0 0 8px)**; :first-child margin-top 15px, :last-child margin-bottom 40px
│ ├ div.%{*…>contextMenuItem}.contextMenuItem.Panel.Focusable[role=menuitem] [state classes below]
│ │ flex, min-width 280px, padding 14px, line-height 20px; bg #23262e; text #b8bcbf
│ ├ div.%{*…>contextMenuItem}.%{*…>SubMenu} > div.%{*…>Label} + div.%{*…>Arrow} > svg.SVGIcon_DownArrowContextMenu (rotated -90deg)
│ ├ div.%{*…>ContextMenuSeparator}[role=separator] 2px; #MainNavMenu-Rest scope: bg #000 (prefers-contrast: #fff)
│ └ … Steam always appends a separator + "Cancel" item
└ (submenu open) a second div.%{*…>contextMenuContents} to the right
Item states (all live classes; see shell_contextmenu_before, shell_contextmenu_submenu_before and shell_powermenu_before):
| class | paint |
|---|---|
| base | #23262e / #b8bcbf |
:hover (laser) |
#3d4450 (100 ms) |
%{*…>Focused} + .gpfocus (focus is shown by %{*BasicContextMenuModal>Focused}, the focusClassName) |
#fff / #0e141b, outline none |
%{*…>Selected} (selected prop / preferred) |
#3d4450 / #fff |
%{*…>active} (submenu open on the parent) |
#fff / #000 |
%{*…>Destructive} |
normal until hover (#8a220f) or focus (#de3618 / #fff). Also .Stop and %{moderation_cancel} |
%{*…>Positive} |
hover #236c39 / #fff, focus #59bf40 / #fff. Also .Play, .Launch, .Resume, .Stream, .Connect, .PlayMusic, .BorrowApp, which have #dcdedf text even when idle |
%{*…>Emphasis} |
hover #216495, focus #1a9fff. Also .Download, .Update, .PreLoad, .Install |
.disabled |
text rgba(255,255,255,.3) (focused: rgba(0,0,0,.5)); not focusable |
.menuChecked |
text #6dcff6 (the .contextMenuCheckMark 4px bar exists but stays display:none in this presentation) |
%{*…>MenuSectionHeader}, %{*…>Capitalized}, %{*…>UpperCase} |
12px uppercase, uppercase, uppercase |
- No pseudo-elements on any context-menu element.
- Backdrop root warning:
filter: drop-shadowoncontextMenuContentsand the opacity animation onBasicContextMenuContainerboth make backdrop roots. Abackdrop-filteron items inside them would only see the menu itself. Put any glass oncontextMenuContents(and replace itsfilter) or on the container after the animation ends. - Related tokens in other modules (desktop-style menus, not seen in VR):
%{ContextMenuMouseOverlay>contextMenu},%{ContextMenuPosition},%{ContextMenuPopup},%{ContextMenuItemDisabled}. - Game-specific menu items such as
%{StreamingContextMenuItem}>%{CheckContainer},%{StreamingTargetLabel}("Play from" menu) and%{*LibraryContextMenu}belong to their areas but sit in this same chrome.
8. Page transitions and overlay scrim
- Route change:
%{TopLevelTransitionSwitch}holds one or two%{ContentWrapper}.%{TopLevelTransition}children with these state classes:%{TopLevelTransitionSwitch>Enter}:opacity 0; transform: scale(.95), orscale(1)when%{NoTransitionZoom}(library).%{…>EnterActive}:opacity 1; transform: scale(1), transition opacity+transform 600 mscubic-bezier(0,0,.1,1), delay 200 ms.%{…>Exit}:opacity 1; scale(1); pointer-events: none.%{…>ExitActive}:opacity 0; scale(.95)over 200 mscubic-bezier(.6,0,1,1).- The Enter and EnterActive classes stay on the element after the transition ends, as seen in every outline.
- The theme must not set
transformoropacityon%{TopLevelTransition}or%{ContentWrapper}.
- App-details overlay (partner event / app details opened over a page):
%{PartnerEventOverlayContainer>AppDetailsMain}cross-fades opacity (…AppDetailsTransitionEnter|EnterActive|Entered|Exit|ExitActive).- The scrim
%{PartnerEventOverlayContainer} %{Container>TransitionWrapper}has opacity 0 by default. In LowPerfMode its bg is rgba(16,16,16,.75). - With
OverlayAppDetailsTransitionEnterActive|Entered|Exitit shows: opacity 1,backdrop-filter: blur(5px) brightness(.65) saturate(.7). - Not reached live, because What's New on Home was empty (
%{LibraryHomeWhatsNew>Empty}). The state comes from CSS.
- The scrim
- Tab-content transitions (
%{ContentWrapper>TransitionGroup},%{GamepadTabbedPage>ContentTransition},%{GamepadTabbedPage>Left}/%{GamepadTabbedPage>Right}) belong to the tabbed-page component (library/search).
9. Focus and hover rendering (generic)
- Mechanics. The gamepad navigation adds
.gpfocusto the focused element and.gpfocuswithinto all its ancestors. Elements that passfocusClassNameget that class too:%{*…>Focused}on menu items.SteamUIStore.ActiveNavigationSourceTypewas1(GAMEPAD). The laser drives:hover(MOUSE = 3). Both must stay visible. In the stylesheets, 704 rules use.gpfocusand 279 use.gpfocuswithin. Generic resets:div:focus, a:focus, span:focus { outline:none },button.DialogButton:focus { outline:none },.ModalPosition:focus { outline:none }. - Per-control focus looks in this area:
- DialogButton and menu item: solid #fff fill with dark text. Primary turns blue, Destructive red, Positive green.
- Text input: #fff fill.
- Header item: #fff fill with black text.
- Search result tile:
%{ResultTemplate}.gpfocuswithin %{ResultTemplateImage}getstransform: scale(1.03)(Steam transform) plusbox-shadow rgba(0,0,0,.32) 0 12px 16px, and its icon and description turn #fff. - Settings-style
%{*GamepadDialogContent>Field}%{*…>HighlightOnFocus}.gpfocus|.gpfocuswithin: bg #3d4450, text #fff, plus the flash animation below.
- Floating focus ring
%{FocusRing}:- It is a div inside a
%{FocusRingRoot}(abs, z 10000, pointer-events none; one under%{BasicHome}and others inside providers such as dialogs). - Inline
left/top/width/height. outline: 2px solid rgba(255,255,255,.6); outline-offset: 2px.animation: bg flash rgba(255,255,255,.08)→0 (.5 s); outline 12px→2px (.4 s); colour fade-in (.4 s); then a pulse of opacity →.4, 20 iterations of 1.2 s.- It appears only for focusables inside a FocusRingProvider that don't set
noFocusRing, for example the zoo Scroll Panel (shell_modal_scrollpanel_before). On Home the root ring was empty. - The
%{DebugFocusRing}and%{FocusRingOnHiddenItem}variants are debug only. %{FocusRingHint}is an inset helper used by the bar.
- It is a div inside a
- Focus-flash animations hold the background. Many
.gpfocusrules set ananimation-namewhose keyframes paintbackgroundandcolor, withanimation-fill-mode: forwards(.5 s). Because animated values beat normal declarations, a theme'sbackground/coloron these focused elements will not apply unless it also replacesanimation-name(with its own keyframes in a*.nowrap.cssfile) or uses!important. Instances:%{*GamepadDialogContent>Field}…HighlightOnFocus.gpfocus→_2wp31…(#23262e→#383a41, text →#fff), or_2NVMb…(rgba(255,255,255,.25→.15)) for Classic fields.%{DashboardMenu>Item}.gpfocus→zjENV…(rgba(255,255,255,.3→.1)).- The global
ItemFocusAnim-*keyframes (darkGrey,darkerGrey,grey,green,translucent-white-10/20,darkGreySettings,darkerGrey-nocolor,ItemFocusAnimBorder-darkGrey) are used by 135 rules, mostly friends and chat.
- Hover: Steam relies on
:hoverfor the laser.- Back: #8b929a→#fff.
- Footer legend: rgba(255,255,255,.1).
- DialogButton: rgba(255,255,255,.2).
- Menu item: #3d4450.
- Result tile:
scale(1.02)plus a shadow. - Search (VR): white icons and placeholder.
- Hover could not be produced synthetically (CSS
:hoverneeds a real pointer), so these states come from CSS.
10. Global CSS variables on the main document
- The design-system variables are not used by the gamepad UI's colours. 674 custom properties are defined, 557 of them on
:root, but component rules hard-code colours (#0e141b, #23262e, #3d4450, #1a9fff, #dcdedf, #8b929a …). The palette variables are only referenced to alias each other. - Overriding
--color-*therefore changes almost nothing in this area. The theme must target tokens. The defined families:- Radix-style colour scales:
--color-{amber,blue,bronze,brown,crimson,cyan,dull,gold,grass,green,greyneutral,indigo,iris,jade,lime,mauve,mint,olive,orange,pink,plum,purple,red,ruby,sage,sand,sky,slate,storegreen,teal,tomato,valveonly,violet,yellow}-{1..12}(plus-aalpha variants). --color-accent-1..12and--color-dull-1..12are aliased by:root:has(%{Root} > div[data-accent-color="blue"])and[data-dull-color="greyneutral"]. Their specificity is about (0,3,1), abovehtml.lgs-on(0,1,1). An override needs a matching:has()selector or!important.--color-accent-8=#1A9FFF,--color-accent-contrast=white.- Text:
--color-text-{light,green,red}-{title,subtitle,body,description,note}(light: #f1f2f3, #d6d9dc, #acb2b8, #9a9fa5, #636b74) and--color-text-body-*. - Inline on the data-accent div:
--color-success-*,--color-warning-*,--color-error-*and--color-text-success|warning|error-*. - Type and spacing scales:
--font-family: "Motiva Sans", Twemoji, "Noto Sans", Helvetica, sans-serif--font-weight-{light 300, regular 400, medium 500, heavy 700}--text-size-1..9(11→36px),--line-height-1..9,--letter-spacing-1..9,--heading-size|line-height|letter-spacing-1..9--spacing-0..9(0,4,8,12,16,24,32,40,48,64px)--radius-{none 0, sm 2px, md 4px, lg 8px, full 9999px}--control-height-1..3(24/36/48px)--box-shadow-elevation-{-1,0,1,2}--default-font-size: 13px,--scroll-fade-size: 20px--gamepad-page-content-max-width: 1100px(inline-overridden per page)--field-negative-horizontal-margin,--field-row-children-spacing,--indent-level,--sticky-header-background-opacity: 0--virtualmenu-*,--touchmenuicon-*(controller virtual menus)--debug-pointer-*
- Referenced but never defined (used with fallbacks):
--gpSpace-Gutter,--gpShadow-Medium,--gpText-BodyMedium|BodyLarge|HeadingLarge,--gpBackground-LightMedium,--gpStoreLightestGrey. Defining them would affect only a handful of rules.
- Radix-style colour scales:
- Variables that matter for the shell. They are read-only: Steam sets them inline as state.
--basicui-header-height(40px) and--gamepadui-current-footer-height(42px) on%{BasicUiRoot}. They positionModalPosition, page padding and tabbed pages. Never override.--gamepadui-header-opacityand--gamepadui-header-background-opacityon#header.--menu-background,--menu-item-height,--menu-icon-size,--menu-item-padding,--menu-font-sizeon%{DashboardMenu}. These are component custom properties a theme can set:--menu-backgroundis the main menu's item fill.
11. Search route /search and /search/tab/{All|Library|Friends|Store|Tools|Hidden}
- Reaching it: typing in the header search navigates to
/search/tab/All(module 62678H). The query lives inreq(4399).U(SetSearchText/GetSearchText().m_currentValue). A tab with 0 results may be hidden: "Hidden" appears only when it has matches. - The search page itself may belong to a content area. It is recorded here because the header search opens it.
div.%{GamepadSearch}.Panel page root: radial gradient (§2); padding-top var(--basicui-header-height)
└ div.%{GamepadTabbedPage}.Panel
├ div.%{TabHeaderRowWrapper}[.%{GamepadTabbedPage>Pinned}][.%{ScrolledDown}] abs top, z 2; ::before (Steam) when scrolled: rgba(0,0,0,.5) + blur(100px) + shadow
│ └ div.%{TabRow} > div.%{Arrows} | div.%{TabRowTabs}[role=tablist] > div.%{TabsRowScroll} > div.%{FixCenterAlignScroll}.%{ScrollPanel}.%{ScrollX}
│ > div.%{GamepadTabbedPage>Tab}[.%{GamepadTabbedPage>Selected}].%{HasAddon}.%{RightAddon}[role=tab] (r 64; Selected bg rgba(255,255,255,.15))
│ > span.%{GamepadTabbedPage>TabTitle} + span.%{TabCount}
└ div.%{TabContents} > div.%{ContentWrapper>TransitionGroup} > div.%{ContentWrapper}.%{GamepadTabbedPage>ContentTransition}
└ div.%{TabContentsScroll}._TabContentsScroll.%{ScrollPanel}.%{ScrollY}[role=tabpanel] ← the scroller
├ div.%{ResultsGridWrapper} > div (inline position:absolute; inset:0)
│ └ div.Panel[role=grid] (inline height: Npx; width:100%; position:relative) ← VIRTUALIZED
│ └ div.%{ResultsRow}[role=row] (inline position:absolute; top:0; height:140px; transform: translateY(Ypx)) grid, auto-fill minmax(168px,1fr), gap 12
│ └ div.%{ResultTemplate}.Panel
│ ├ div.%{ResultTemplateImage}.Panel[role=link] margin 6px 0; :hover scale(1.02); focus-within scale(1.03) + shadow
│ │ ├ game/store: div.%{LibraryImageWithName} > div.%{PortraitImage>Container}.%{GreyBackground}.%{LandscapeImage}.%{GamepadSearch>GameIcon} (bg #0e141b) > img.%{PortraitImage>Image}
│ │ ├ friend: div.%{FriendResultImage} > div.%{ProfileBackground} > div.%{miniProfileBackground} > img.%{miniProfileBackgroundBlur} (op .4)
│ │ │ + div.%{GamepadSearch>ImageContainer} > div.%{avatarHolder}.avatarHolder.%{SearchResultFriendAvatar}[.online|.offline] > div.avatarStatus + img.%{avatar}
│ │ └ "View more in the Store": div.%{RedirectResultBackground} (linear-gradient 135deg #67707b→#3d4450) + div.%{OverlaidText} (12px bold uppercase #dcdedf)
│ └ div.%{ResultTemplateDescriptionRow} > div.%{GamepadSearch>Icon} (svg 16px #8b929a) + div.%{GamepadSearch>Description} (12px bold uppercase #8b929a; "In Library" / "From the store" / persona name)
└ empty: div.%{NoResultsFound} > span "No Results Found" (16px #fff, centred, margin-top 162px)
- Do not touch: the inline
transform: translateY(),top,heightandpositionon%{ResultsRow}and the grid wrappers (virtual list). The transforms on%{ResultTemplateImage}(hover/focus scale). %{LibraryImageWithName}%{TagCtn} %{GamepadSearch>GameIcon}gets a purple gradient; it was not observed.
12. Things a theme must not touch (consolidated, shell)
| element | Steam-owned property |
|---|---|
%{BasicUiRoot} |
inline --basicui-header-height, --gamepadui-current-footer-height; border-radius: 6px + overflow: hidden (window clip) |
#header |
inline --gamepadui-header-opacity / --gamepadui-header-background-opacity; opacity; pointer-events |
%{SearchFieldBackground}, %{SearchIconLeft}, %{SearchIconRight}, %{SearchBox}::placeholder |
transform / opacity transitions (expand animation) |
%{GamepadDialogOverlay} |
inline display:none toggle |
ModalPosition |
top / bottom from vars, overflow (scroll) |
dialog card, %{*BasicContextMenuModal>BasicContextMenuContainer} |
entrance animations on opacity + transform |
%{TopLevelTransition} / %{ContentWrapper} |
route transition opacity + transform |
%{PartnerEventOverlayContainer>AppDetailsMain}, %{Container>TransitionWrapper} |
opacity / backdrop-filter transitions |
%{FocusRing} |
inline left/top/width/height + animations |
%{ResultsRow} + grid wrappers |
inline virtual-list position/top/height/transform |
%{ResultTemplateImage} |
hover/focus transform: scale() |
%{DashboardMenu>Item} (frame.menu) |
`transform: scale(1 |
#MouseHoverBlockerHack (if present) |
everything |
13. Scroll containers in this area
| container | scroll behaviour |
|---|---|
%{*GamepadDialogContent>ModalPosition} |
overflow: hidden auto |
%{*…>contextMenuContents} |
overflow: hidden auto, scroll-padding 72px |
zoo/custom dialog %{ScrollPanel}.%{ScrollY} |
scrolls |
search %{TabContentsScroll} (%{ScrollPanel}.%{ScrollY}) |
virtualized rows |
tab strip %{FixCenterAlignScroll}.%{ScrollPanel}.%{ScrollX} |
horizontal |
%{FooterLegend} |
overflow: hidden (clips) |
Scrollbars are hidden globally (body.%{GamepadUIPopupWindowBody} ::-webkit-scrollbar { display:none }).
14. Steam's own pseudo-elements (shell)
| element | pseudo-element |
|---|---|
%{Profile>Header}::before |
header backdrop (§3) |
%{BackContainer}:not(:last-child)::after |
1px divider |
%{TabHeaderRowWrapper}::before |
when %{IsUnderHeader} or %{ScrolledDown}: pinned tab-row backdrop (tabbed-page component) |
%{Profile>CurrentUserAvatar}.InVoiceChat::after |
header avatar voice indicator; not visible |
input[type=search]::-webkit-search-cancel-button |
clear (x) |
::placeholder |
styled |
button.DialogButton::before |
Steam sets content: none, so it is free to use |
%{*GamepadDialogContent>Field}%{*…>Classic}…::after |
separators (settings area) |
%{DashboardMenu>ItemOuter}::after |
%{Variant_TabMenu} only (bar area) |
No pseudo-elements on: %{BasicHome}, %{BasicFooter}, the scrim, the dialog card, DialogHeader/Body/Footer, any context-menu element, or the search result tiles. Each was checked with getComputedStyle(el,'::before'|'::after').content.
15. Stock palette in this area (for mapping to glass tokens)
| colour | used for |
|---|---|
| #0e141b | window gradient end, dialog card, main-menu items, focused text |
| #060a0e | gradient start |
| #23262e | menu item bg, dialog border, divider |
| #3d4450 | hover / selected item, focused field |
| #1a9fff | primary / accent |
| #de3618 | destructive focus (hover #8a220f) |
| #59bf40 | positive focus (hover #236c39) |
| #216495 | emphasis hover |
| #dcdedf | body text |
| #b8bcbf | menu text, filled search field |
| #8b929a | secondary text and icons |
| #67707b | placeholder |
| #6dcff6 | checked item |
| rgba(0,0,0,.5) + blur(100px) | header and footer bars |
| rgba(0,0,0,.85) + blur(3px) | modal scrim |
| rgba(255,255,255,.15) | unfocused button and selected tab |
16. Could not reach / not captured (and why)
| item | why |
|---|---|
| Search field gamepad-focused (#fff field) | Focusing triggers onKeyboardShow (raises the shared VR keyboard surface and navigates). Documented from CSS |
All :hover states (laser) |
Synthetic events don't set CSS :hover. Documented from CSS |
App-details / partner-event overlay scrim (%{Container>TransitionWrapper} visible) |
What's New was empty, so there was nothing to open |
Header browser mode (%{HeaderBrowser}, URL bar in header on /steamweb / /externalweb) |
Needs a live Store web view; belongs with the store area. Rule: .FlexGrowWebBrowserURLBar %{HeaderBrowser} { flex-grow: 1 }, Back container removed |
Header account-alert item (#header_profile, %{Profile>HasActiveSupportAlert} red gradient) |
Only rendered with active support alerts |
%{HeaderOpaque}, %{InQuickAccess}, %{SuppressInteraction} header variants |
The header is not rendered on the opaque routes in VR. Quick Access is not a side menu in VR. Suppression is disabled in VR (ci() returns false when IN_VR) |
Stacked modals (.ModalOverlayContent.inactive) |
Opening a dialog over a menu hides the menu, so they never coexisted |
.FullModalOverlay.NotReadyToRender (black) |
Transient |
Desktop-style menus (%{ContextMenuMouseOverlay}, %{ContextMenuPopup}) |
The gamepad presentation is always used in VR |
Root %{FocusRingRoot} ring on Home |
Library tiles use their own focus styling (noFocusRing). The ring was captured inside a dialog instead |
17. Test bench: /zoo
Steam's component zoo works in VR. These routes are safe to look at:
/zoo/modals: demo context menus (Simple, Deeply Nested, Confirmation Example, AppActionsMenu, Longboi ×2) and dialogs (OK/Cancel, GenericConfirmDialog, Text Prompt, Empty, Scroll Panel Test, Launch Multiple Games, Non-Interactive, Long Non-Interactive)./zoo/buttons,/zoo/dropdowns,/zoo/sliders,/zoo/toggles,/zoo/fieldlayouts,/zoo/misc,/zoo/glyphs,/zoo/type,/zoo/input: generic controls for after-shots of the shared gamepaddialog styles.- Avoid
/zoo/clientsettings,/zoo/developeroptionsand/zoo/vrdeveloperoptions. They hold real settings. - Avoid "AppActionsMenu" items and "Launch Multiple Games Dialog" confirms.
- Close zoo modals with
MM.RemoveModal(entry)as in ZOO (§0.2).