diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Dev/ContextMenuDemo.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Dev/ContextMenuDemo.razor
new file mode 100644
index 00000000..db5cd1da
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Dev/ContextMenuDemo.razor
@@ -0,0 +1,35 @@
+@page "/dev/context-menu-demo"
+@* Dev-only host page to live-verify the reusable ContextMenu component. *@
+@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
+@rendermode InteractiveServer
+
+ContextMenu demo
+
+
ContextMenu demo
+Right-click the card below, or use the ⋯ button.
+
+
+
+
+ Right-click me
+
+
+
+
+
+
+Last action: @_lastAction
+
+@code {
+ private string _lastAction = "(none)";
+
+ private IReadOnlyList _items => new List
+ {
+ new() { Label = "Edit", Icon = "✏️", OnClick = () => Set("Edit") },
+ new() { Label = "Duplicate", Icon = "📄", OnClick = () => Set("Duplicate") },
+ ContextMenuItem.Separator(),
+ new() { Label = "Delete", Icon = "🗑️", Disabled = true, OnClick = () => Set("Delete") },
+ };
+
+ private void Set(string action) => _lastAction = action;
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenu.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenu.razor
new file mode 100644
index 00000000..ea8b00fc
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenu.razor
@@ -0,0 +1,168 @@
+@* Reusable right-click / context menu.
+
+ Consumers get two (independently optional) triggers over one item list:
+ • ChildContent — wrapped as a right-clickable surface (browser menu suppressed).
+ • ShowEllipsis — a "⋯" button, the keyboard/touch fallback for the right-click.
+
+ Both triggers open the same menu anchored at the pointer. Outside-click closes
+ via a transparent full-viewport backdrop (the standard Blazor dropdown pattern —
+ no JS alert/confirm). Keyboard: Arrow up/down move selection, Enter activates,
+ Esc closes. *@
+
+@if (ChildContent is not null)
+{
+
+ @ChildContent
+
+}
+
+@if (ShowEllipsis)
+{
+
+}
+
+@if (_open)
+{
+ @* Transparent catch-all: any click that misses the menu closes it. *@
+
+
+
+}
+
+@code {
+ private static readonly System.Globalization.CultureInfo Culture =
+ System.Globalization.CultureInfo.InvariantCulture;
+
+ /// The rows to show. Separators are permitted.
+ [Parameter] public IReadOnlyList Items { get; set; } = Array.Empty();
+
+ /// Wrapped as the right-clickable surface. Optional.
+ [Parameter] public RenderFragment? ChildContent { get; set; }
+
+ /// Render the "⋯" trigger button (keyboard/touch fallback).
+ [Parameter] public bool ShowEllipsis { get; set; } = true;
+
+ private bool _open;
+ private double _x;
+ private double _y;
+ private int _activeIndex = -1;
+ private ElementReference _menuRef;
+ private bool _focusPending;
+
+ // Both triggers carry a MouseEventArgs, so the menu anchors at the pointer
+ // (right-click point, or the click point on the "⋯" button) with no JS.
+ private void OpenAt(MouseEventArgs e)
+ {
+ _x = e.ClientX;
+ _y = e.ClientY;
+ _activeIndex = FirstEnabledIndex();
+ _open = true;
+ _focusPending = true;
+ }
+
+ private void Close()
+ {
+ _open = false;
+ _activeIndex = -1;
+ }
+
+ private void Activate(ContextMenuItem item)
+ {
+ if (item.Disabled || item.IsSeparator) return;
+ Close();
+ item.OnClick?.Invoke();
+ }
+
+ private void OnKeyDown(KeyboardEventArgs e)
+ {
+ switch (e.Key)
+ {
+ case "Escape":
+ Close();
+ break;
+ case "ArrowDown":
+ Move(1);
+ break;
+ case "ArrowUp":
+ Move(-1);
+ break;
+ case "Enter":
+ case " ":
+ if (_activeIndex >= 0 && _activeIndex < Items.Count)
+ {
+ Activate(Items[_activeIndex]);
+ }
+ break;
+ }
+ }
+
+ // Advance the selection in the given direction, skipping separators and
+ // disabled rows, wrapping around the ends.
+ private void Move(int dir)
+ {
+ if (Items.Count == 0) return;
+ var i = _activeIndex;
+ for (var step = 0; step < Items.Count; step++)
+ {
+ i = (i + dir + Items.Count) % Items.Count;
+ var item = Items[i];
+ if (!item.IsSeparator && !item.Disabled)
+ {
+ _activeIndex = i;
+ return;
+ }
+ }
+ }
+
+ private int FirstEnabledIndex()
+ {
+ for (var i = 0; i < Items.Count; i++)
+ {
+ if (!Items[i].IsSeparator && !Items[i].Disabled) return i;
+ }
+ return -1;
+ }
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (_focusPending)
+ {
+ _focusPending = false;
+ try
+ {
+ await _menuRef.FocusAsync();
+ }
+ catch
+ {
+ // The menu may have closed before the focus round-trip completed.
+ }
+ }
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenu.razor.css b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenu.razor.css
new file mode 100644
index 00000000..df025623
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenu.razor.css
@@ -0,0 +1,93 @@
+/* Scoped styles for ContextMenu. Colours come from the shared ZB.MOM.WW.Theme
+ tokens (theme.css) so the menu tracks the app palette in light and dark. */
+
+.ctx-surface {
+ display: inline;
+}
+
+/* The "⋯" fallback trigger — a compact, quiet icon button. */
+.ctx-ellipsis {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.75rem;
+ height: 1.75rem;
+ padding: 0;
+ border: 1px solid transparent;
+ border-radius: 0.375rem;
+ background: transparent;
+ color: var(--ink-soft);
+ font-size: 1.1rem;
+ line-height: 1;
+ cursor: pointer;
+}
+
+ .ctx-ellipsis:hover,
+ .ctx-ellipsis[aria-expanded="true"] {
+ background: var(--hover-bg);
+ border-color: var(--bs-border-color);
+ color: var(--ink);
+ }
+
+/* Transparent full-viewport catch-all; sits just under the menu. */
+.ctx-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 1080;
+ background: transparent;
+}
+
+.ctx-menu {
+ position: fixed;
+ z-index: 1081; /* above the backdrop, below toasts (1090) */
+ min-width: 11rem;
+ max-width: 20rem;
+ padding: 0.25rem;
+ background: var(--card);
+ border: 1px solid var(--bs-border-color);
+ border-radius: 0.5rem;
+ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.16);
+ outline: none;
+}
+
+.ctx-item {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ width: 100%;
+ padding: 0.35rem 0.6rem;
+ border: 0;
+ border-radius: 0.375rem;
+ background: transparent;
+ color: var(--ink);
+ font-size: 0.875rem;
+ text-align: left;
+ cursor: pointer;
+}
+
+ .ctx-item:hover:not(:disabled),
+ .ctx-item.active:not(:disabled) {
+ background: var(--hover-bg);
+ }
+
+ .ctx-item:disabled {
+ color: var(--ink-faint);
+ cursor: not-allowed;
+ }
+
+.ctx-icon {
+ display: inline-flex;
+ flex: 0 0 auto;
+ width: 1.1rem;
+ justify-content: center;
+}
+
+.ctx-label {
+ flex: 1 1 auto;
+}
+
+.ctx-separator {
+ height: 1px;
+ margin: 0.25rem 0.3rem;
+ background: var(--bs-border-color);
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenuItem.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenuItem.cs
new file mode 100644
index 00000000..a513f7f0
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenuItem.cs
@@ -0,0 +1,28 @@
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared;
+
+///
+/// One entry in a . An item is either an actionable
+/// row ( + ) or a visual divider
+/// ( = true, in which case the other members are
+/// ignored). Use the factory for dividers.
+///
+public sealed class ContextMenuItem
+{
+ /// Text shown for the row.
+ public string Label { get; init; } = "";
+
+ /// Invoked when the row is activated (click / Enter).
+ public Action? OnClick { get; init; }
+
+ /// Optional leading icon — an emoji or single glyph.
+ public string? Icon { get; init; }
+
+ /// Renders the row greyed-out and non-interactive.
+ public bool Disabled { get; init; }
+
+ /// When true this entry is a horizontal divider, not a row.
+ public bool IsSeparator { get; init; }
+
+ /// A horizontal divider between groups of items.
+ public static ContextMenuItem Separator() => new() { IsSeparator = true };
+}