diff --git a/.editorconfig b/.editorconfig
index 5598b6120f..567d809b49 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -151,6 +151,7 @@ resharper_trailing_comma_in_multiline_lists = true
 resharper_wrap_array_initializer_style = chop_if_long
 resharper_wrap_before_primary_constructor_declaration_rpar = true
 resharper_wrap_chained_binary_expressions = chop_if_long
+resharper_wrap_chained_method_calls = chop_if_long
 ###############################
 # VB Coding Conventions       #
 ###############################
diff --git a/Framework/Intersect.Framework.Core/Config/DatabaseOptions.cs b/Framework/Intersect.Framework.Core/Config/DatabaseOptions.cs
index 2de57095a8..3a6e6c47bc 100644
--- a/Framework/Intersect.Framework.Core/Config/DatabaseOptions.cs
+++ b/Framework/Intersect.Framework.Core/Config/DatabaseOptions.cs
@@ -5,6 +5,7 @@
 
 namespace Intersect.Config;
 
+[RequiresRestart]
 public partial class DatabaseOptions
 {
 #if DEBUG
diff --git a/Framework/Intersect.Framework.Core/Config/OptionsStrings.Designer.cs b/Framework/Intersect.Framework.Core/Config/OptionsStrings.Designer.cs
new file mode 100644
index 0000000000..6d9382468b
--- /dev/null
+++ b/Framework/Intersect.Framework.Core/Config/OptionsStrings.Designer.cs
@@ -0,0 +1,114 @@
+//------------------------------------------------------------------------------
+// <auto-generated>
+//     This code was generated by a tool.
+//
+//     Changes to this file may cause incorrect behavior and will be lost if
+//     the code is regenerated.
+// </auto-generated>
+//------------------------------------------------------------------------------
+
+namespace Intersect.Framework.Core.Config {
+    using System;
+    
+    
+    [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
+    [System.Diagnostics.DebuggerNonUserCodeAttribute()]
+    [System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
+    internal class OptionsStrings {
+        
+        private static System.Resources.ResourceManager resourceMan;
+        
+        private static System.Globalization.CultureInfo resourceCulture;
+        
+        [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
+        internal OptionsStrings() {
+        }
+        
+        [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)]
+        internal static System.Resources.ResourceManager ResourceManager {
+            get {
+                if (object.Equals(null, resourceMan)) {
+                    System.Resources.ResourceManager temp = new System.Resources.ResourceManager("Intersect.Framework.Core.Config.OptionsStrings", typeof(OptionsStrings).Assembly);
+                    resourceMan = temp;
+                }
+                return resourceMan;
+            }
+        }
+        
+        [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)]
+        internal static System.Globalization.CultureInfo Culture {
+            get {
+                return resourceCulture;
+            }
+            set {
+                resourceCulture = value;
+            }
+        }
+        
+        internal static string Category_General {
+            get {
+                return ResourceManager.GetString("Category_General", resourceCulture);
+            }
+        }
+        
+        internal static string Category_Logging {
+            get {
+                return ResourceManager.GetString("Category_Logging", resourceCulture);
+            }
+        }
+        
+        internal static string Category_Metrics {
+            get {
+                return ResourceManager.GetString("Category_Metrics", resourceCulture);
+            }
+        }
+        
+        internal static string Category_GameDatabase {
+            get {
+                return ResourceManager.GetString("Category_GameDatabase", resourceCulture);
+            }
+        }
+        
+        internal static string Category_LoggingDatabase {
+            get {
+                return ResourceManager.GetString("Category_LoggingDatabase", resourceCulture);
+            }
+        }
+        
+        internal static string Category_PlayerDatabase {
+            get {
+                return ResourceManager.GetString("Category_PlayerDatabase", resourceCulture);
+            }
+        }
+        
+        internal static string Category_Security {
+            get {
+                return ResourceManager.GetString("Category_Security", resourceCulture);
+            }
+        }
+        
+        internal static string Category_SmtpSettings {
+            get {
+                return ResourceManager.GetString("Category_SmtpSettings", resourceCulture);
+            }
+        }
+        
+        internal static string Category_Chat {
+            get {
+                return ResourceManager.GetString("Category_Chat", resourceCulture);
+            }
+        }
+        
+        internal static string Category_Packets {
+            get {
+                return ResourceManager.GetString("Category_Packets", resourceCulture);
+            }
+        }
+        
+        internal static string Category_Combat {
+            get {
+                return ResourceManager.GetString("Category_Combat", resourceCulture);
+            }
+        }
+    }
+}
diff --git a/Framework/Intersect.Framework.Core/Config/OptionsStrings.en.resx b/Framework/Intersect.Framework.Core/Config/OptionsStrings.en.resx
new file mode 100644
index 0000000000..a5dee20842
--- /dev/null
+++ b/Framework/Intersect.Framework.Core/Config/OptionsStrings.en.resx
@@ -0,0 +1,89 @@
+<root>
+  <resheader name="resmimetype">
+    <value>text/microsoft-resx</value>
+  </resheader>
+  <resheader name="version">
+    <value>1.3</value>
+  </resheader>
+  <resheader name="reader">
+    <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+  <resheader name="writer">
+    <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+  <data name="Category_General" xml:space="preserve">
+    <value>General</value>
+  </data>
+  <data name="Category_Chat" xml:space="preserve">
+    <value>Chat</value>
+  </data>
+  <data name="Category_GameDatabase" xml:space="preserve">
+    <value>Database (Game)</value>
+  </data>
+  <data name="Category_Logging" xml:space="preserve">
+    <value>Logging</value>
+  </data>
+  <data name="Category_LoggingDatabase" xml:space="preserve">
+    <value>Database (Logging)</value>
+  </data>
+  <data name="Category_Metrics" xml:space="preserve">
+    <value>Metrics</value>
+  </data>
+  <data name="Category_Packets" xml:space="preserve">
+    <value>Packets</value>
+  </data>
+  <data name="Category_PlayerDatabase" xml:space="preserve">
+    <value>Database (Player)</value>
+  </data>
+  <data name="Category_Security" xml:space="preserve">
+    <value>Security</value>
+  </data>
+  <data name="Category_SmtpSettings" xml:space="preserve">
+    <value>Email</value>
+  </data>
+  <data name="Category_Combat" xml:space="preserve">
+    <value>Combat</value>
+  </data>
+  <data name="Category_Equipment" xml:space="preserve">
+    <value>Equipment</value>
+  </data>
+  <data name="Category_Passability" xml:space="preserve">
+    <value>Passability</value>
+  </data>
+  <data name="Category_Map" xml:space="preserve">
+    <value>Map</value>
+  </data>
+  <data name="Category_Player" xml:space="preserve">
+    <value>Player</value>
+  </data>
+  <data name="Category_Party" xml:space="preserve">
+    <value>Party</value>
+  </data>
+  <data name="Category_Loot" xml:space="preserve">
+    <value>Loot</value>
+  </data>
+  <data name="Category_Processing" xml:space="preserve">
+    <value>Processing</value>
+  </data>
+  <data name="Category_Sprites" xml:space="preserve">
+    <value>Sprites</value>
+  </data>
+  <data name="Category_Npc" xml:space="preserve">
+    <value>NPC</value>
+  </data>
+  <data name="Category_Quest" xml:space="preserve">
+    <value>Quest</value>
+  </data>
+  <data name="Category_Guild" xml:space="preserve">
+    <value>Guild</value>
+  </data>
+  <data name="Category_Bank" xml:space="preserve">
+    <value>Bank</value>
+  </data>
+  <data name="Category_Instancing" xml:space="preserve">
+    <value>Instancing</value>
+  </data>
+  <data name="Category_Items" xml:space="preserve">
+    <value>Items</value>
+  </data>
+</root>
\ No newline at end of file
diff --git a/Framework/Intersect.Framework.Core/Config/OptionsStrings.it.resx b/Framework/Intersect.Framework.Core/Config/OptionsStrings.it.resx
new file mode 100644
index 0000000000..346a82c29d
--- /dev/null
+++ b/Framework/Intersect.Framework.Core/Config/OptionsStrings.it.resx
@@ -0,0 +1,26 @@
+<root>
+  <resheader name="resmimetype">
+    <value>text/microsoft-resx</value>
+  </resheader>
+  <resheader name="version">
+    <value>1.3</value>
+  </resheader>
+  <resheader name="reader">
+    <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+  <resheader name="writer">
+    <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+  <data name="Category_General" xml:space="preserve">
+    <value>Generale</value>
+  </data>
+  <data name="Category_SmtpSettings" xml:space="preserve">
+    <value>Email</value>
+  </data>
+  <data name="Category_Security" xml:space="preserve">
+    <value>Sicurezza</value>
+  </data>
+  <data name="Category_Player" xml:space="preserve">
+    <value>Giocatori</value>
+  </data>
+</root>
\ No newline at end of file
diff --git a/Framework/Intersect.Framework.Core/Config/OptionsStrings.resx b/Framework/Intersect.Framework.Core/Config/OptionsStrings.resx
new file mode 100644
index 0000000000..9d35009392
--- /dev/null
+++ b/Framework/Intersect.Framework.Core/Config/OptionsStrings.resx
@@ -0,0 +1,96 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<root>
+  <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
+    <xsd:element name="root" msdata:IsDataSet="true">
+
+    </xsd:element>
+  </xsd:schema>
+  <resheader name="resmimetype">
+    <value>text/microsoft-resx</value>
+  </resheader>
+  <resheader name="version">
+    <value>1.3</value>
+  </resheader>
+  <resheader name="reader">
+    <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+  <resheader name="writer">
+    <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+  <data name="Category_General" xml:space="preserve">
+    <value>General</value>
+  </data>
+  <data name="Category_Logging" xml:space="preserve">
+    <value>Logging</value>
+  </data>
+  <data name="Category_Metrics" xml:space="preserve">
+    <value>Metrics</value>
+  </data>
+  <data name="Category_GameDatabase" xml:space="preserve">
+    <value>Database (Game)</value>
+  </data>
+  <data name="Category_LoggingDatabase" xml:space="preserve">
+    <value>Database (Logging)</value>
+  </data>
+  <data name="Category_PlayerDatabase" xml:space="preserve">
+    <value>Database (Player)</value>
+  </data>
+  <data name="Category_Security" xml:space="preserve">
+    <value>Security</value>
+  </data>
+  <data name="Category_SmtpSettings" xml:space="preserve">
+    <value>Email</value>
+  </data>
+  <data name="Category_Chat" xml:space="preserve">
+    <value>Chat</value>
+  </data>
+  <data name="Category_Packets" xml:space="preserve">
+    <value>Packets</value>
+  </data>
+  <data name="Category_Combat" xml:space="preserve">
+    <value>Combat</value>
+  </data>
+  <data name="Category_Equipment" xml:space="preserve">
+    <value>Equipment</value>
+  </data>
+  <data name="Category_Passability" xml:space="preserve">
+    <value>Passability</value>
+  </data>
+  <data name="Category_Map" xml:space="preserve">
+    <value>Map</value>
+  </data>
+  <data name="Category_Player" xml:space="preserve">
+    <value>Player</value>
+  </data>
+  <data name="Category_Party" xml:space="preserve">
+    <value>Party</value>
+  </data>
+  <data name="Category_Loot" xml:space="preserve">
+    <value>Loot</value>
+  </data>
+  <data name="Category_Processing" xml:space="preserve">
+    <value>Processing</value>
+  </data>
+  <data name="Category_Sprites" xml:space="preserve">
+    <value>Sprites</value>
+  </data>
+  <data name="Category_Npc" xml:space="preserve">
+    <value>NPC</value>
+  </data>
+  <data name="Category_Quest" xml:space="preserve">
+    <value>Quest</value>
+  </data>
+  <data name="Category_Guild" xml:space="preserve">
+    <value>Guild</value>
+  </data>
+  <data name="Category_Bank" xml:space="preserve">
+    <value>Bank</value>
+  </data>
+  <data name="Category_Instancing" xml:space="preserve">
+    <value>Instancing</value>
+  </data>
+  <data name="Category_Items" xml:space="preserve">
+    <value>Items</value>
+  </data>
+</root>
\ No newline at end of file
diff --git a/Framework/Intersect.Framework.Core/GameObjects/DescriptorStrings.Designer.cs b/Framework/Intersect.Framework.Core/GameObjects/DescriptorStrings.Designer.cs
new file mode 100644
index 0000000000..6e74f2a7d5
--- /dev/null
+++ b/Framework/Intersect.Framework.Core/GameObjects/DescriptorStrings.Designer.cs
@@ -0,0 +1,54 @@
+//------------------------------------------------------------------------------
+// <auto-generated>
+//     This code was generated by a tool.
+//
+//     Changes to this file may cause incorrect behavior and will be lost if
+//     the code is regenerated.
+// </auto-generated>
+//------------------------------------------------------------------------------
+
+namespace Intersect.Framework.Core.GameObjects {
+    using System;
+    
+    
+    [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
+    [System.Diagnostics.DebuggerNonUserCodeAttribute()]
+    [System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
+    internal class DescriptorStrings {
+        
+        private static System.Resources.ResourceManager resourceMan;
+        
+        private static System.Globalization.CultureInfo resourceCulture;
+        
+        [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
+        internal DescriptorStrings() {
+        }
+        
+        [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)]
+        internal static System.Resources.ResourceManager ResourceManager {
+            get {
+                if (object.Equals(null, resourceMan)) {
+                    System.Resources.ResourceManager temp = new System.Resources.ResourceManager("Intersect.Framework.Core.GameObjects.DescriptorStrings", typeof(DescriptorStrings).Assembly);
+                    resourceMan = temp;
+                }
+                return resourceMan;
+            }
+        }
+        
+        [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)]
+        internal static System.Globalization.CultureInfo Culture {
+            get {
+                return resourceCulture;
+            }
+            set {
+                resourceCulture = value;
+            }
+        }
+        
+        internal static string VariableDescriptor_EditorTable {
+            get {
+                return ResourceManager.GetString("VariableDescriptor_EditorTable", resourceCulture);
+            }
+        }
+    }
+}
diff --git a/Framework/Intersect.Framework.Core/GameObjects/DescriptorStrings.resx b/Framework/Intersect.Framework.Core/GameObjects/DescriptorStrings.resx
new file mode 100644
index 0000000000..092d36615d
--- /dev/null
+++ b/Framework/Intersect.Framework.Core/GameObjects/DescriptorStrings.resx
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<root>
+  <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
+    <xsd:element name="root" msdata:IsDataSet="true">
+      
+    </xsd:element>
+  </xsd:schema>
+  <resheader name="resmimetype">
+    <value>text/microsoft-resx</value>
+  </resheader>
+  <resheader name="version">
+    <value>1.3</value>
+  </resheader>
+  <resheader name="reader">
+    <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+  <resheader name="writer">
+    <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
+  </resheader>
+  <data name="VariableDescriptor_EditorTable" xml:space="preserve">
+    <value />
+  </data>
+</root>
\ No newline at end of file
diff --git a/Framework/Intersect.Framework.Core/Intersect.Framework.Core.csproj b/Framework/Intersect.Framework.Core/Intersect.Framework.Core.csproj
index 5bf630af26..8407e1aebb 100644
--- a/Framework/Intersect.Framework.Core/Intersect.Framework.Core.csproj
+++ b/Framework/Intersect.Framework.Core/Intersect.Framework.Core.csproj
@@ -8,6 +8,21 @@
       <ProjectReference Include="..\Intersect.Framework\Intersect.Framework.csproj" />
     </ItemGroup>
 
+  <ItemGroup>
+    <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
+      <_Parameter1>Intersect Client</_Parameter1>
+    </AssemblyAttribute>
+    <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
+      <_Parameter1>Intersect.Client.Core</_Parameter1>
+    </AssemblyAttribute>
+    <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
+      <_Parameter1>Intersect Server</_Parameter1>
+    </AssemblyAttribute>
+    <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
+      <_Parameter1>Intersect.Server.Core</_Parameter1>
+    </AssemblyAttribute>
+  </ItemGroup>
+
     <ItemGroup>
       <PackageReference Include="Ceras" Version="4.1.7" />
       <PackageReference Include="K4os.Compression.LZ4" Version="1.3.6" />
@@ -24,4 +39,28 @@
       <PackageReference Include="System.IO.FileSystem" Version="4.3.0" />
     </ItemGroup>
 
+    <ItemGroup>
+      <EmbeddedResource Update="GameObjects\DescriptorStrings.resx">
+        <Generator>ResXFileCodeGenerator</Generator>
+        <LastGenOutput>DescriptorStrings.Designer.cs</LastGenOutput>
+      </EmbeddedResource>
+      <EmbeddedResource Update="Config\OptionsStrings.resx">
+        <Generator>ResXFileCodeGenerator</Generator>
+        <LastGenOutput>OptionsStrings.Designer.cs</LastGenOutput>
+      </EmbeddedResource>
+    </ItemGroup>
+
+    <ItemGroup>
+      <Compile Update="GameObjects\DescriptorStrings.Designer.cs">
+        <DesignTime>True</DesignTime>
+        <AutoGen>True</AutoGen>
+        <DependentUpon>DescriptorStrings.resx</DependentUpon>
+      </Compile>
+      <Compile Update="Config\OptionsStrings.Designer.cs">
+        <DesignTime>True</DesignTime>
+        <AutoGen>True</AutoGen>
+        <DependentUpon>OptionsStrings.resx</DependentUpon>
+      </Compile>
+    </ItemGroup>
+
 </Project>
diff --git a/Framework/Intersect.Framework/Reflection/TypeExtensions.cs b/Framework/Intersect.Framework/Reflection/TypeExtensions.cs
index 29dbe96598..58628c9af0 100755
--- a/Framework/Intersect.Framework/Reflection/TypeExtensions.cs
+++ b/Framework/Intersect.Framework/Reflection/TypeExtensions.cs
@@ -55,6 +55,10 @@ public static string[] GetMappedColumnNames(this Type type)
             .ToArray();
     }
 
+    public static bool IsEnumerable(this Type type) => typeof(IEnumerable<>).ExtendedBy(type);
+
+    public static bool IsEnumerable<TValue>(this Type type) => typeof(IEnumerable<TValue>).ExtendedBy(type);
+
     public static bool IsIntegral(this Type type) =>
         type == typeof(int) ||
         type == typeof(long) ||
diff --git a/Framework/Intersect.Framework/Resources/ResourceManagerExtensions.cs b/Framework/Intersect.Framework/Resources/ResourceManagerExtensions.cs
index df983ccef1..75a37c6b7a 100644
--- a/Framework/Intersect.Framework/Resources/ResourceManagerExtensions.cs
+++ b/Framework/Intersect.Framework/Resources/ResourceManagerExtensions.cs
@@ -8,10 +8,13 @@ public static class ResourceManagerExtensions
     public static string? GetStringWithFallback(
         this ResourceManager resourceManager,
         string name,
-        CultureInfo? cultureInfo
+        CultureInfo? cultureInfo = null,
+        bool fallbackToResourceName = false
     )
     {
-        while (cultureInfo != null && cultureInfo.LCID != CultureInfo.InvariantCulture.LCID)
+        cultureInfo ??= CultureInfo.CurrentCulture;
+
+        while (cultureInfo.LCID != CultureInfo.InvariantCulture.LCID)
         {
             var value = resourceManager.GetString(name, cultureInfo);
             if (!string.IsNullOrWhiteSpace(value))
@@ -22,6 +25,6 @@ public static class ResourceManagerExtensions
             cultureInfo = cultureInfo.Parent;
         }
 
-        return null;
+        return fallbackToResourceName ? name : null;
     }
 }
\ No newline at end of file
diff --git a/Intersect.Server/Web/Pages/Components/WebComponents/TabSet/_TabSet.cshtml b/Intersect.Server/Web/Pages/Components/WebComponents/TabSet/_TabSet.cshtml
new file mode 100644
index 0000000000..15d161083a
--- /dev/null
+++ b/Intersect.Server/Web/Pages/Components/WebComponents/TabSet/_TabSet.cshtml
@@ -0,0 +1,121 @@
+<!--suppress CssUnresolvedCustomProperty -->
+<template id="custom-element-tab-content">
+    <style>
+        :host > .content {
+            display:  contents;
+        }
+    </style>
+    <div class="content" role="tabpanel" aria-labelledby="manifests" aria-hidden="true">
+        <slot></slot>
+    </div>
+</template>
+
+<template id="custom-element-tab-set">
+    <style>
+        :host {
+            display: block;
+        }
+
+        :host > .container {
+            display: flex;
+            flex-direction: column;
+        }
+
+        :host([nav-style=side]) > .container {
+            flex-direction: row;
+        }
+
+        :host > .container > .triggers {
+            flex-direction: row;
+        }
+
+        :host([nav-style=side]) > .container > .triggers {
+            display: flex;
+            flex-direction: column;
+            max-height: 100%;
+            overflow-y: auto;
+        }
+
+        .container,
+        input,
+        label,
+        ::slotted(tab-content) {
+            border-collapse: var(--tabs-border-collapse, collapse);
+        }
+
+        .container {
+            border: var(--tabs-border-width_container, var(--tabs-border-width, 0)) solid var(--tabs-border-color_container, var(--tabs-border-color));
+        }
+
+        input.trigger {
+            display: none;
+        }
+
+        input.trigger+label {
+            background-color: var(--tabs-trigger-color);
+            border-style: solid;
+            border-width: 0 0.1em;
+            border-color: var(--tabs-trigger-color-selected, #bbb);
+            display: inline-flex;
+            padding: var(--tabs-trigger-padding, 0.5em 2em);
+        }
+
+        input.trigger:checked+label,
+        input.trigger+label:hover {
+            background-color: var(--tabs-trigger-color-selected, #bbb);
+        }
+
+        :host([nav-style=side]) input.trigger+label {
+            border-width: 0.1em 0 0 0;
+            padding: var(--tabs-trigger-padding, 0.5em 1em);
+        }
+
+        input.trigger+label:hover {
+            filter:  brightness(125%);
+        }
+
+        input.trigger+label>svg {
+            max-height: 1.25lh;
+        }
+
+        input.trigger+label>svg.material {
+            fill: currentColor;
+            margin-right: 0.5em;
+            min-width: calc(min(1.5em, 24px));
+        }
+
+        input.trigger+label>span {
+            min-width: fit-content;
+        }
+
+        svg.hidden {
+            display: none;
+        }
+
+        ::slotted(tab-content) {
+            border-top: var(--tabs-trigger-border-width, 0.0625rem) solid var(--tabs-trigger-color-selected, #bbb);
+            display: none;
+            flex: 1;
+            padding: 1em;
+        }
+
+        ::slotted(tab-content.selected) {
+            display: block;
+        }
+    </style>
+    <div class="container" role="tablist" data-tab-group="asset-management">
+        <template id="trigger">
+            <input type="radio" class="trigger" name="tabs"/>
+            <label role="tab" aria-controls="panel-browse" tabindex="0">
+                <svg class="material hidden" viewBox="0 0 24 24" aria-hidden="true">
+                    <use/>
+                </svg>
+                <span>Tab Label</span>
+            </label>
+        </template>
+        <div class="triggers"></div>
+        <slot></slot>
+    </div>
+</template>
+
+<script src="~/js/components/tabset/tabset.js"></script>
\ No newline at end of file
diff --git a/Intersect.Server/Web/Pages/Developer/Index.cshtml.cs b/Intersect.Server/Web/Pages/Developer/Index.cshtml.cs
index 14dc50897f..80931b445d 100644
--- a/Intersect.Server/Web/Pages/Developer/Index.cshtml.cs
+++ b/Intersect.Server/Web/Pages/Developer/Index.cshtml.cs
@@ -6,7 +6,14 @@ namespace Intersect.Server.Web.Pages.Developer;
 [Authorize(Policy = "Developer")]
 public class DeveloperIndexModel : PageModel
 {
-    public static readonly DeveloperFeature[] AllFeatures = [];
+    public static readonly DeveloperFeature[] AllFeatures = [
+        new(
+            "ServerSettings",
+            "/Developer/ServerSettings",
+            () => "STUBServerSettings",
+            context => false
+        ),
+    ];
 
     public IEnumerable<DeveloperFeature> EnabledFeatures =>
         AllFeatures.Where(feature => feature.EnablementProvider?.Invoke(HttpContext) ?? true);
diff --git a/Intersect.Server/Web/Pages/Developer/ServerSettings/Index.cshtml b/Intersect.Server/Web/Pages/Developer/ServerSettings/Index.cshtml
new file mode 100644
index 0000000000..1940256bf0
--- /dev/null
+++ b/Intersect.Server/Web/Pages/Developer/ServerSettings/Index.cshtml
@@ -0,0 +1,34 @@
+@page "/Developer/ServerSettings"
+@using System.Reflection
+@using Intersect.Framework.Core.Config
+@using Intersect.Framework.Reflection
+@using Intersect.Framework.Resources
+@using Microsoft.AspNetCore.Html
+@using Microsoft.AspNetCore.Mvc.TagHelpers
+@model ServerSettingsIndexModel
+
+@{
+    ViewData["Title"] = DeveloperWebResources.DeveloperPortal;
+}
+
+@await Html.PartialAsync("~/Web/Pages/Components/WebComponents/TabSet/_TabSet.cshtml")
+
+<article class="page">
+    <h1>Server Config</h1>
+
+    <form>
+        <tab-set nav-style="side" style="--tabs-trigger-color: var(--theme-bg-accent); --tabs-trigger-color-selected: var(--theme-bg-accent-hover); --tabs-trigger-border-width: 0.125em;">
+            @foreach (var (groupKey, propertyInfos) in ServerSettingsIndexModel.PropertyGroups)
+            {
+                <tab-content tab-id="@groupKey" tab-label="@OptionsStrings.ResourceManager.GetStringWithFallback(groupKey, fallbackToResourceName: true)"
+                             @*tab-icon="/material/notification/account_tree/materialiconsoutlined/24px.svg#root"*@>
+                    @foreach (var propertyInfo in propertyInfos)
+                    {
+                        <partial name="_Property.partial" model="@(Model.GetModelFor(propertyInfo))"/>
+                    }
+                </tab-content>
+            }
+        </tab-set>
+        @* <partial name="_Type.partial" model="@(new TypePartialPageModel(Model.Logger, target: Options.Instance.DeepClone(), typeof(Options), isEditing: true, isRoot: true))"/> *@
+    </form>
+</article>
\ No newline at end of file
diff --git a/Intersect.Server/Web/Pages/Developer/ServerSettings/Index.cshtml.cs b/Intersect.Server/Web/Pages/Developer/ServerSettings/Index.cshtml.cs
new file mode 100644
index 0000000000..865fd294a1
--- /dev/null
+++ b/Intersect.Server/Web/Pages/Developer/ServerSettings/Index.cshtml.cs
@@ -0,0 +1,64 @@
+using System.Reflection;
+using Intersect.Framework.Reflection;
+using Microsoft.AspNetCore.JsonPatch;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Intersect.Server.Web.Pages.Developer.ServerSettings;
+
+public class ServerSettingsIndexModel(ILogger<ServerSettingsIndexModel> logger) : PageModel
+{
+    private const string CategoryGeneral = "Category_General";
+
+    public readonly ILogger<ServerSettingsIndexModel> Logger = logger;
+
+    private static string GetGroupKey(PropertyInfo propertyInfo)
+    {
+        if (propertyInfo.PropertyType.IsValueType)
+        {
+            return CategoryGeneral;
+        }
+
+        if (propertyInfo.PropertyType == typeof(string))
+        {
+            return CategoryGeneral;
+        }
+
+        // ReSharper disable once ConvertIfStatementToReturnStatement
+        if (propertyInfo.PropertyType.IsEnumerable())
+        {
+            return CategoryGeneral;
+        }
+
+        return $"Category_{propertyInfo.Name}";
+    }
+
+    public static readonly Dictionary<string, PropertyInfo[]> PropertyGroups = typeof(Options)
+        .GetProperties(BindingFlags.Instance | BindingFlags.Public)
+        .Where(propertyInfo => !propertyInfo.IsIgnored())
+        .GroupBy(GetGroupKey)
+        .ToDictionary(group => group.Key, group => group.ToArray());
+
+    public Options Target { get; set; } = Options.Instance.DeepClone();
+
+    public void OnGet()
+    {
+
+    }
+
+    public void OnPush(JsonPatchDocument<Options> optionsChanges)
+    {
+
+    }
+
+    public PropertyPartialPageModel GetModelFor(PropertyInfo propertyInfo) =>
+        new(
+            Logger,
+            target: Target,
+            parentId: null,
+            propertyInfo,
+            isEditing: true
+        )
+        {
+            IsRoot = true,
+        };
+}
\ No newline at end of file
diff --git a/Intersect.Server/Web/Pages/Developer/ServerSettings/Index.cshtml.css b/Intersect.Server/Web/Pages/Developer/ServerSettings/Index.cshtml.css
new file mode 100644
index 0000000000..c370d66ba3
--- /dev/null
+++ b/Intersect.Server/Web/Pages/Developer/ServerSettings/Index.cshtml.css
@@ -0,0 +1,11 @@
+::deep form {
+	width: 100%;
+}
+
+::deep form tab-content.selected,
+::deep span.field > fieldset {
+	display: grid;
+	grid-gap: 0.5em;
+	grid-template-columns: minmax(min-content, 20em) minmax(min-content, 20em);
+	grid-auto-rows: min-content;
+}
diff --git a/Intersect.Server/Web/Pages/Developer/ServerSettings/MemberInfoPageModel.cs b/Intersect.Server/Web/Pages/Developer/ServerSettings/MemberInfoPageModel.cs
new file mode 100644
index 0000000000..f46707e486
--- /dev/null
+++ b/Intersect.Server/Web/Pages/Developer/ServerSettings/MemberInfoPageModel.cs
@@ -0,0 +1,24 @@
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Intersect.Server.Web.Pages.Developer.ServerSettings;
+
+public abstract class MemberInfoPageModel<TInfoType>(
+    ILogger logger,
+    object target,
+    TInfoType info,
+    bool isEditing = false,
+    string? parentId = null
+) : PageModel
+{
+    public TInfoType Info { get; } = info;
+
+    public bool IsEditing { get; } = isEditing;
+
+    public ILogger Logger { get; } = logger;
+
+    public abstract string OwnId { get; }
+
+    public string? ParentId { get; } = parentId;
+
+    public object Target { get; } = target;
+}
\ No newline at end of file
diff --git a/Intersect.Server/Web/Pages/Developer/ServerSettings/PropertyInfoPageModel.cs b/Intersect.Server/Web/Pages/Developer/ServerSettings/PropertyInfoPageModel.cs
new file mode 100644
index 0000000000..79d1550a82
--- /dev/null
+++ b/Intersect.Server/Web/Pages/Developer/ServerSettings/PropertyInfoPageModel.cs
@@ -0,0 +1,21 @@
+using System.Reflection;
+
+namespace Intersect.Server.Web.Pages.Developer.ServerSettings;
+
+public abstract class PropertyInfoPageModel(
+    ILogger logger,
+    object target,
+    string? parentId,
+    PropertyInfo propertyInfo,
+    bool isEditing = false
+)
+    : MemberInfoPageModel<PropertyInfo>(
+        logger: logger,
+        target: target,
+        info: propertyInfo,
+        isEditing: isEditing,
+        parentId: parentId
+    )
+{
+    public override string OwnId => ParentId == null ? Info.Name : string.Join('.', ParentId, Info.Name);
+}
\ No newline at end of file
diff --git a/Intersect.Server/Web/Pages/Developer/ServerSettings/TypePageModel.cs b/Intersect.Server/Web/Pages/Developer/ServerSettings/TypePageModel.cs
new file mode 100644
index 0000000000..23bdefd95a
--- /dev/null
+++ b/Intersect.Server/Web/Pages/Developer/ServerSettings/TypePageModel.cs
@@ -0,0 +1,32 @@
+using System.Reflection;
+using Intersect.Framework.Reflection;
+
+namespace Intersect.Server.Web.Pages.Developer.ServerSettings;
+
+public abstract class TypePageModel(
+    ILogger logger,
+    object target,
+    Type type,
+    bool isEditing = false,
+    string? parentId = null
+)
+    : MemberInfoPageModel<Type>(
+        logger: logger,
+        target: target,
+        info: type,
+        isEditing: isEditing,
+        parentId: parentId
+    )
+{
+
+    public List<MemberInfo> Members =>
+        ((MemberInfo[])
+        [
+            ..Type.GetProperties(BindingFlags.Instance | BindingFlags.Public),
+            ..Type.GetFields(BindingFlags.Instance | BindingFlags.Public)
+        ]).Where(memberInfo => !memberInfo.IsIgnored()).ToList();
+
+    public override string OwnId => ParentId;
+
+    public Type Type { get; } = type;
+}
\ No newline at end of file
diff --git a/Intersect.Server/Web/Pages/Developer/ServerSettings/_Property.partial.cshtml b/Intersect.Server/Web/Pages/Developer/ServerSettings/_Property.partial.cshtml
new file mode 100644
index 0000000000..b6ce5167cb
--- /dev/null
+++ b/Intersect.Server/Web/Pages/Developer/ServerSettings/_Property.partial.cshtml
@@ -0,0 +1,70 @@
+@using Intersect.Framework.Reflection
+@model PropertyPartialPageModel
+
+@if (Model.Info.PropertyType.IsPrimitive || Model.Info.PropertyType == typeof(string))
+{
+    <span class="@Model.ClassString">
+        <label for="@Model.OwnId">@Model.Info.Name</label>
+        @if (Model.Info.PropertyType.IsIntegral())
+        {
+            <input id="@Model.OwnId" name="@Model.OwnId" disabled=@Model.IsReadOnly readonly=@Model.IsReadOnly type="number" value="@Model.Info.GetValue(Model.Target)" />
+        }
+        else if (Model.Info.PropertyType.IsFloatingPoint())
+        {
+            <input id="@Model.OwnId" name="@Model.OwnId" disabled=@Model.IsReadOnly readonly=@Model.IsReadOnly type="number" value="@Model.Info.GetValue(Model.Target)" />
+        }
+        else if (Model.Info.PropertyType == typeof(string))
+        {
+            <input id="@Model.OwnId" name="@Model.OwnId" disabled=@Model.IsReadOnly readonly=@Model.IsReadOnly type="@(Model.Info.IsPassword() ? "password" : "text")"
+                   value="@Model.Info.GetValue(Model.Target)" />
+        }
+        else if (Model.Info.PropertyType == typeof(bool))
+        {
+            <input id="@Model.OwnId" name="@Model.OwnId" disabled=@Model.IsReadOnly type="checkbox"
+                   checked=@Model.Info.GetValue(Model.Target)/>
+        }
+        else
+        {
+            <span>STUB_@Model.Info.PropertyType.GetName(qualified: true)</span>
+        }
+    </span>
+}
+else if (Model.Info.PropertyType.IsEnum)
+{
+    @if (Model.Info.PropertyType.IsBitflags())
+    {
+        <span class="@Model.ClassString">
+            <label for="@Model.OwnId">@Model.Info.Name</label>
+            STUB_BITFLAGS
+        </span>
+    }
+    else
+    {
+        <span class="@Model.ClassString">
+            <label for="@Model.OwnId">@Model.Info.Name</label>
+            <select id="@Model.OwnId" name="@Model.OwnId" disabled=@Model.IsReadOnly>
+                @foreach (var enumValue in Model.Info.PropertyType.GetValues(excludeIgnored: true))
+                {
+                    <option value="@enumValue" selected=@(Model.Info.GetValue(Model.Target)?.Equals(enumValue) ?? false)>@enumValue</option>
+                }
+            </select>
+        </span>
+    }
+}
+else if (typeof(IEnumerable<>).ExtendedBy(Model.Info.PropertyType))
+{
+    <span class="@Model.ClassString">
+        <label for="@Model.OwnId">@Model.Info.Name</label>
+        STUB_ENUMERABLE
+    </span>
+}
+else if (Model.IsRoot)
+{
+    <partial name="_Type.partial" model="@(new TypePartialPageModel(Model.Logger, target: Model.Info.GetValue(Model.Target), Model.Info.PropertyType, isEditing: Model.IsEditing, parentId: Model.OwnId, sectionName: Model.Info.Name, isRoot: true))"/>
+}
+else
+{
+    <span class="@Model.ClassString">
+        <partial name="_Type.partial" model="@(new TypePartialPageModel(Model.Logger, target: Model.Info.GetValue(Model.Target), Model.Info.PropertyType, isEditing: Model.IsEditing, parentId: Model.OwnId, sectionName: Model.Info.Name))"/>
+    </span>
+}
\ No newline at end of file
diff --git a/Intersect.Server/Web/Pages/Developer/ServerSettings/_Property.partial.cshtml.cs b/Intersect.Server/Web/Pages/Developer/ServerSettings/_Property.partial.cshtml.cs
new file mode 100644
index 0000000000..09575b090c
--- /dev/null
+++ b/Intersect.Server/Web/Pages/Developer/ServerSettings/_Property.partial.cshtml.cs
@@ -0,0 +1,47 @@
+using System.Reflection;
+using Intersect.Framework.Annotations;
+using Intersect.Framework.Reflection;
+
+namespace Intersect.Server.Web.Pages.Developer.ServerSettings;
+
+public class PropertyPartialPageModel(
+    ILogger logger,
+    object target,
+    string? parentId,
+    PropertyInfo propertyInfo,
+    bool isEditing = false
+)
+    : PropertyInfoPageModel(
+        logger: logger,
+        target: target,
+        parentId: parentId,
+        propertyInfo: propertyInfo,
+        isEditing: isEditing
+    )
+{
+    public bool IsRoot { get; init; }
+
+    public string ClassString
+    {
+        get
+        {
+            List<string> classes = ["field"];
+            if (IsEditing && !Info.PropertyType.IsReadOnly())
+            {
+                classes.Add("editing");
+            }
+            else
+            {
+                classes.Add("display");
+            }
+            if (RequiresRestartAttribute.RequiresRestart(Info))
+            {
+                classes.Add("requires-restart");
+            }
+
+            return string.Join(' ', classes);
+        }
+    }
+
+    public bool IsReadOnly => !IsEditing || Info.IsReadOnly();
+}
\ No newline at end of file
diff --git a/Intersect.Server/Web/Pages/Developer/ServerSettings/_Type.partial.cshtml b/Intersect.Server/Web/Pages/Developer/ServerSettings/_Type.partial.cshtml
new file mode 100644
index 0000000000..1d42ac70f3
--- /dev/null
+++ b/Intersect.Server/Web/Pages/Developer/ServerSettings/_Type.partial.cshtml
@@ -0,0 +1,14 @@
+@using Intersect.Framework.Reflection
+@model TypePartialPageModel
+
+@if (Model.IsRoot)
+{
+    <partial name="_TypeBody.partial" model="@(new TypeBodyPartialPageModel(Model.Logger, target: Model.Target, Model.Type, isEditing: Model.IsEditing && !Model.Type.IsReadOnly(), parentId: Model.ParentId))"/>
+}
+else
+{
+    <fieldset data-id="@Model.OwnId">
+        <legend>@(Model.SectionName ?? Model.Info.Name)</legend>
+        <partial name="_TypeBody.partial" model="@(new TypeBodyPartialPageModel(Model.Logger, target: Model.Target, Model.Type, isEditing: Model.IsEditing && !Model.Type.IsReadOnly(), parentId: Model.ParentId))"/>
+    </fieldset>
+}
\ No newline at end of file
diff --git a/Intersect.Server/Web/Pages/Developer/ServerSettings/_Type.partial.cshtml.cs b/Intersect.Server/Web/Pages/Developer/ServerSettings/_Type.partial.cshtml.cs
new file mode 100644
index 0000000000..06d8fb292e
--- /dev/null
+++ b/Intersect.Server/Web/Pages/Developer/ServerSettings/_Type.partial.cshtml.cs
@@ -0,0 +1,25 @@
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Intersect.Server.Web.Pages.Developer.ServerSettings;
+
+public class TypePartialPageModel(
+    ILogger logger,
+    object target,
+    Type type,
+    bool isEditing = false,
+    string? parentId = null,
+    bool isRoot = false,
+    string? sectionName = null
+)
+    : TypePageModel(
+        logger: logger,
+        target: target,
+        type: type,
+        isEditing: isEditing,
+        parentId: parentId
+    )
+{
+    public bool IsRoot { get; } = isRoot;
+
+    public string? SectionName { get; } = sectionName;
+}
\ No newline at end of file
diff --git a/Intersect.Server/Web/Pages/Developer/ServerSettings/_TypeBody.partial.cshtml b/Intersect.Server/Web/Pages/Developer/ServerSettings/_TypeBody.partial.cshtml
new file mode 100644
index 0000000000..d000883a9d
--- /dev/null
+++ b/Intersect.Server/Web/Pages/Developer/ServerSettings/_TypeBody.partial.cshtml
@@ -0,0 +1,22 @@
+@using System.Reflection
+@using Intersect.Framework.Reflection
+
+@model TypeBodyPartialPageModel
+
+@foreach (var memberInfo in Model.Members)
+{
+    switch (memberInfo)
+    {
+        case FieldInfo fieldInfo:
+            Model.Logger.LogDebug("Unsupported member field: {FieldType} {FieldName}", fieldInfo.FieldType.GetName(qualified: true), fieldInfo.Name);
+            <span>Unsupported member field: @(string.IsNullOrWhiteSpace(Model.OwnId) ? fieldInfo.Name : $"{Model.OwnId}.{fieldInfo.Name}")</span>
+            break;
+        case PropertyInfo propertyInfo:
+            <partial name="_Property.partial" model="@(new PropertyPartialPageModel(Model.Logger, target: Model.Target, Model.OwnId, propertyInfo, isEditing: Model.IsEditing))"/>
+            break;
+        default:
+            Model.Logger.LogDebug("Unsupported member: {MemberType} {MemberName}", memberInfo.MemberType, memberInfo.Name);
+            <span>Unsupported member @memberInfo.MemberType: @(string.IsNullOrWhiteSpace(Model.OwnId) ? memberInfo.Name : $"{Model.OwnId}.{memberInfo.Name}")</span>
+            break;
+    }
+}
\ No newline at end of file
diff --git a/Intersect.Server/Web/Pages/Developer/ServerSettings/_TypeBody.partial.cshtml.cs b/Intersect.Server/Web/Pages/Developer/ServerSettings/_TypeBody.partial.cshtml.cs
new file mode 100644
index 0000000000..b586974a3a
--- /dev/null
+++ b/Intersect.Server/Web/Pages/Developer/ServerSettings/_TypeBody.partial.cshtml.cs
@@ -0,0 +1,20 @@
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace Intersect.Server.Web.Pages.Developer.ServerSettings;
+
+public class TypeBodyPartialPageModel(
+    ILogger logger,
+    object target,
+    Type type,
+    bool isEditing = false,
+    string? parentId = null
+)
+    : TypePageModel(
+        logger: logger,
+        target: target,
+        type: type,
+        isEditing: isEditing,
+        parentId: parentId
+    )
+{
+}
\ No newline at end of file
diff --git a/Intersect.Server/Web/Pages/Shared/_Layout.cshtml.css b/Intersect.Server/Web/Pages/Shared/_Layout.cshtml.css
index 506874e04c..22b19c13ff 100644
--- a/Intersect.Server/Web/Pages/Shared/_Layout.cshtml.css
+++ b/Intersect.Server/Web/Pages/Shared/_Layout.cshtml.css
@@ -67,4 +67,5 @@ main {
 	display: flex;
 	flex-direction: column;
 	align-items: center;
+	width: 100%;
 }
diff --git a/Intersect.Server/wwwroot/css/site.css b/Intersect.Server/wwwroot/css/site.css
index ac814a1fc0..02d22f369f 100644
--- a/Intersect.Server/wwwroot/css/site.css
+++ b/Intersect.Server/wwwroot/css/site.css
@@ -302,7 +302,44 @@ form span {
 	/*border: 1px solid #bbb;*/
 	display: block;
 	position: relative;
-	padding-top: 1.5em;
+	margin-top: 1.5em;
+}
+
+form span.field {
+	margin-top: 0.5em;
+}
+
+form span:first-of-type,
+form span.field:first-of-type {
+	margin-top: initial;
+}
+
+form span.field {
+	display: grid;
+	grid-template-columns: subgrid;
+	grid-column: 1 / 3;
+}
+
+form span.field > label {
+	display: inline-flex;
+	justify-content: end;
+	align-items: center;
+}
+
+form span.field > label+* {
+	text-align: start;
+}
+
+form span.field > label+input[type=checkbox] {
+	width: min-content;
+}
+
+fieldset {
+	padding: 0 0.5em 0.5em;
+}
+
+form span.field > fieldset {
+	grid-column: 1 / 3;
 }
 
 form span > input {
diff --git a/Intersect.Server/wwwroot/js/components/tabset/tabset.js b/Intersect.Server/wwwroot/js/components/tabset/tabset.js
new file mode 100644
index 0000000000..3c4ce1ea9a
--- /dev/null
+++ b/Intersect.Server/wwwroot/js/components/tabset/tabset.js
@@ -0,0 +1,350 @@
+class TabContentVisibleEvent extends CustomEvent {
+	static NAME = 'tabcontentvisible';
+
+	constructor() {
+		super(TabContentVisibleEvent.NAME, {
+			detail: {}
+		});
+	}
+}
+
+class TabContentElement extends HTMLElement {
+	static observedAttributes = [
+		'tab-icon',
+		'tab-id',
+		'tab-label'
+	];
+
+	/** @type {HTMLElement} */
+	#contentContainer;
+
+	/** @type {boolean} */
+	#selected = false;
+
+	/** @type {string?} */
+	#tabIcon;
+
+	/** @type {string?} */
+	#tabId;
+
+	/** @type {string?} */
+	#tabLabel;
+
+	/** @type {boolean} */
+	get selected() {
+		return this.#selected;
+	}
+
+	/** @param {boolean} value */
+	set selected(value) {
+		if (this.#selected === value) {
+			return;
+		}
+
+		this.#selected = typeof value === 'boolean' ? value : Boolean(value);
+		this.#contentContainer?.setAttribute('aria-hidden', !this.#selected);
+		if (value) {
+			this.classList.add('selected');
+			this.dispatchEvent(new TabContentVisibleEvent());
+		} else {
+			this.classList.remove('selected');
+		}
+	}
+
+	/** @type {string} */
+	get tabIcon() {
+		return this.#tabIcon;
+	}
+
+	/** @param {string?} value */
+	set tabIcon(value) {
+		this.#tabIcon = value;
+	}
+
+	/** @type {string} */
+	get tabId() {
+		return this.#tabId;
+	}
+
+	/** @param {string?} value */
+	set tabId(value) {
+		this.#tabId = value;
+	}
+
+	/** @type {string} */
+	get tabLabel() {
+		return this.#tabLabel;
+	}
+
+	/** @param {string?} value */
+	set tabLabel(value) {
+		this.#tabLabel = value;
+	}
+
+	constructor() {
+		super();
+
+		this.attachShadow({
+			mode: 'open'
+		});
+	}
+
+	#render() {
+		/** @type {HTMLTemplateElement} */
+		const template = document.querySelector('template#custom-element-tab-content');
+		this.shadowRoot.appendChild(template.content.cloneNode(true));
+
+		this.#contentContainer = this.shadowRoot.querySelector('div.content');
+		this.#contentContainer.setAttribute('aria-hidden', !this.#selected);
+	}
+
+	connectedCallback() {
+		this.#render();
+	}
+
+	disconnectedCallback() {
+	}
+
+	adoptedCallback() {
+	}
+
+	attributeChangedCallback(name, oldValue, newValue) {
+		if (oldValue === newValue) {
+			return;
+		}
+
+		const camelName = kebabToCamelCase(name);
+		switch (name) {
+			case 'tab-icon':
+			case 'tab-id':
+			case 'tab-label':
+				this[camelName] = newValue;
+				break;
+
+			default:
+				this[name] = newValue;
+				break;
+		}
+	}
+}
+
+customElements.define('tab-content', TabContentElement);
+
+class TabSetElement extends HTMLElement {
+	static observedAttributes = [
+		'nav-style'
+	];
+
+	/** @type {HTMLElement} */
+	#elementContainer;
+
+	/** @type {HTMLElement} */
+	#triggerContainer;
+
+	/** @type {HTMLTemplateElement} */
+	#triggerTemplate;
+
+	/** @type {HTMLSlotElement} */
+	#slotContents;
+
+	/** @type {TabContentElement[]} */
+	#contentElements = [];
+
+	/** @type {number} */
+	#selectedIndex = 0;
+
+	constructor() {
+		super();
+
+		this.attachShadow({
+			mode: 'open'
+		});
+	}
+
+	get selectedIndex() {
+		return this.#selectedIndex;
+	}
+
+	/**
+	 *
+	 * @param {number} value
+	 */
+	set selectedIndex(value) {
+		if (this.#selectedIndex === value) {
+			return;
+		}
+
+		const currentTab = this.#contentElements[this.#selectedIndex];
+		if (currentTab !== undefined) {
+			const { tabId } = currentTab;
+			currentTab.selected = false;
+			const trigger = this.#triggerContainer.querySelector(`input[data-tab-id="${tabId}"]`);
+			if (trigger !== null) {
+				trigger.checked = false;
+			}
+		}
+
+		this.#selectedIndex = value;
+
+		const selectedTab = this.#contentElements[this.#selectedIndex];
+		if (selectedTab !== undefined) {
+			const { tabId } = selectedTab;
+			selectedTab.selected = true;
+			const trigger = this.#triggerContainer.querySelector(`input[data-tab-id="${tabId}"]`);
+			if (trigger !== null) {
+				trigger.checked = true;
+			}
+		}
+	}
+
+	#render() {
+		/** @type {HTMLTemplateElement} */
+		const template = document.querySelector('template#custom-element-tab-set');
+		this.shadowRoot.appendChild(template.content.cloneNode(true));
+
+		this.#elementContainer = this.shadowRoot.querySelector('div.container');
+		this.#triggerContainer = this.shadowRoot.querySelector('div.triggers');
+		this.#triggerTemplate = this.shadowRoot.querySelector('template#trigger');
+
+		this.#slotContents = this.shadowRoot.querySelector('slot:not([name])');
+
+		const onSlotChanged = () => {
+			/** @type {TabContentElement[]} */
+			const tabs = this.#slotContents.assignedElements().filter(e => e instanceof TabContentElement);
+
+			/** @type {string[]} */
+			const tabIds = [];
+
+			for (const tab of tabs) {
+				const tabId = tab.tabId?.trim();
+				const tabIcon = tab.tabIcon?.trim();
+
+				if (typeof tabId !== 'string' || tabId.length < 1) {
+					console.error('Invalid tab, `tab-id` attribute is not set', tab);
+					continue;
+				}
+
+				const isSelected = this.#selectedIndex === tabIds.length;
+
+				tabIds.push(tabId);
+
+				/** @type {HTMLInputElement | null} */
+				let input = this.#triggerContainer.querySelector(`input[data-tab-id="${tab.tabId}"]`);
+
+				/** @type {HTMLLabelElement | null} */
+				let label = this.#triggerContainer.querySelector(`label[data-tab-id="${tab.tabId}"]`);
+
+				if (input === null || label === null) {
+					if (input !== null) {
+						this.#triggerContainer.removeChild(input);
+					}
+
+					if (label !== null) {
+						this.#triggerContainer.removeChild(label);
+					}
+
+					/** @type {DocumentFragment} */
+					const nodes = this.#triggerTemplate.content.cloneNode(true);
+					input = nodes.querySelector('input');
+					label = nodes.querySelector('label');
+
+					input.setAttribute('data-tab-id', tabId);
+					label.setAttribute('data-tab-id', tabId);
+
+					input.id = `trigger-${tabId}`;
+					label.id = `label-${tabId}`;
+					label.htmlFor = input.id;
+
+					const [lastTabId] = tabIds.slice(-1);
+					const [lastTab] = lastTabId === undefined ? [null] : [...this.#triggerContainer.querySelectorAll(`[data-tab-id="${lastTabId}"]`)];
+					const nextSibling = lastTab?.nextSibling;
+					this.#triggerContainer.insertBefore(input, nextSibling);
+					this.#triggerContainer.insertBefore(label, nextSibling);
+
+					input.addEventListener('change', ({ target }) => {
+						if (target?.tagName !== 'INPUT') {
+							console.error('Invalid target, expected an `<input />` element', target);
+							return;
+						}
+
+						/** @type {string | null} */
+						const selectedTabId = target.getAttribute('data-tab-id')?.trim();
+						if (typeof selectedTabId !== 'string' || selectedTabId.length < 1) {
+							console.error('Invalid target, the `<input />` has no `data-tab-id` attribute', target);
+							return;
+						}
+
+						const nextSelectedIndex = this.#contentElements.findIndex(e => e.tabId === selectedTabId);
+						console.info(`Selecting tab ${nextSelectedIndex}`);
+						this.#contentElements[this.#selectedIndex]?.classList.remove('selected');
+						this.#selectedIndex = nextSelectedIndex;
+						this.#contentElements[this.#selectedIndex]?.classList.add('selected');
+					});
+
+					label.addEventListener('keypress', evt => {
+						if (evt.key === ' ' || evt.key === 'Enter') {
+							const { target } = evt;
+							const tabId = target.getAttribute('data-tab-id');
+							const tabIndex = this.#contentElements.findIndex(tab => tab.tabId === tabId);
+							console.debug(`Selecting tab ${tabIndex} (${tabId}) with the keyboard`);
+							this.selectedIndex = tabIndex;
+						}
+					});
+				}
+
+				if (typeof tabIcon === 'string' && tabIcon.length > 0) {
+					const iconUse = label.querySelector('use');
+					iconUse.setAttribute('href', tabIcon);
+					iconUse.parentElement.classList.remove('hidden');
+				}
+
+				input.checked = isSelected;
+
+				label.querySelector('span').textContent = tab.tabLabel?.trim();
+
+				tab.id = `panel-${tabId}`;
+				tab.setAttribute('aria-labelledby', label.id);
+				tab.selected = isSelected;
+
+				label.setAttribute('aria-controls', tab.id);
+			}
+
+			for (const element of this.#triggerContainer.children) {
+				switch (element.tagName) {
+					case 'INPUT':
+					case 'LABEL':
+						if (!tabIds.includes(element.getAttribute('data-tab-id'))) {
+							this.#triggerContainer.removeChild(element);
+						}
+						break;
+				}
+			}
+
+			this.#contentElements = tabs;
+		};
+
+		this.#slotContents.addEventListener('slotchange', onSlotChanged);
+	}
+
+	connectedCallback() {
+		this.#render();
+	}
+
+	disconnectedCallback() {
+	}
+
+	adoptedCallback() {
+	}
+
+	attributeChangedCallback(name, oldValue, newValue) {
+		if (oldValue !== newValue) {
+			if (name === 'selected-index') {
+				this.selectedIndex = newValue;
+			} else {
+				this[name] = newValue;
+			}
+		}
+	}
+}
+
+customElements.define('tab-set', TabSetElement);