diff --git a/packages/pluggableWidgets/skiplink-web/.gitignore b/packages/pluggableWidgets/skiplink-web/.gitignore
new file mode 100644
index 0000000000..a1bd0102fd
--- /dev/null
+++ b/packages/pluggableWidgets/skiplink-web/.gitignore
@@ -0,0 +1,14 @@
+/tests/TestProjects/**/.classpath
+/tests/TestProjects/**/.project
+/tests/TestProjects/**/javascriptsource
+/tests/TestProjects/**/javasource
+/tests/TestProjects/**/resources
+/tests/TestProjects/**/userlib
+
+/tests/TestProjects/Mendix8/theme/styles/native
+/tests/TestProjects/Mendix8/theme/styles/web/sass
+/tests/TestProjects/Mendix8/theme/*.*
+!/tests/TestProjects/Mendix8/theme/components.json
+!/tests/TestProjects/Mendix8/theme/favicon.ico
+!/tests/TestProjects/Mendix8/theme/LICENSE
+!/tests/TestProjects/Mendix8/theme/settings.json
diff --git a/packages/pluggableWidgets/skiplink-web/.prettierrc.js b/packages/pluggableWidgets/skiplink-web/.prettierrc.js
new file mode 100644
index 0000000000..0892704ab0
--- /dev/null
+++ b/packages/pluggableWidgets/skiplink-web/.prettierrc.js
@@ -0,0 +1 @@
+module.exports = require("@mendix/prettier-config-web-widgets");
diff --git a/packages/pluggableWidgets/skiplink-web/README.md b/packages/pluggableWidgets/skiplink-web/README.md
new file mode 100644
index 0000000000..02b3c70903
--- /dev/null
+++ b/packages/pluggableWidgets/skiplink-web/README.md
@@ -0,0 +1,27 @@
+# SkipLink Web Widget
+
+A simple accessibility widget that adds a skip link to the top of the page. The link is only visible when focused and allows users to jump directly to the main content.
+
+## Usage
+
+1. Place the `` component at the very top of your page or layout.
+2. Ensure your main content container has `id="main-content"`.
+
+ ```jsx
+
+ Main content here
+ ```
+
+## Accessibility
+
+- The skip link is visually hidden except when focused, making it accessible for keyboard and screen reader users.
+
+## End-to-End Testing
+
+E2E tests are located in the `e2e/` folder and use Playwright. Run them with:
+
+```
+npm install
+npx playwright install
+npm test
+```
diff --git a/packages/pluggableWidgets/skiplink-web/e2e/SkipLink.spec.ts b/packages/pluggableWidgets/skiplink-web/e2e/SkipLink.spec.ts
new file mode 100644
index 0000000000..80c13e8155
--- /dev/null
+++ b/packages/pluggableWidgets/skiplink-web/e2e/SkipLink.spec.ts
@@ -0,0 +1,23 @@
+import { expect, test } from "@playwright/test";
+
+// Assumes the test project renders and a element
+
+test.describe("SkipLink", () => {
+ test("should be hidden by default and visible on focus, and should skip to main content", async ({ page }) => {
+ await page.goto("/");
+ const skipLink = page.locator(".skip-link");
+ // Should be hidden by default
+ await expect(skipLink).toHaveCSS("transform", "matrix(1, 0, 0, 1, 0, -120)");
+ // Tab to focus the skip link
+ await page.keyboard.press("Tab");
+ await expect(skipLink).toBeVisible();
+ // Check if skipLink is the active element
+ const isFocused = await skipLink.evaluate(node => node === document.activeElement);
+ expect(isFocused).toBe(true);
+ // Press Enter to activate the link
+ await page.keyboard.press("Enter");
+ // The main content should be focused or scrolled into view
+ const main = page.locator("#main-content");
+ await expect(main).toBeVisible();
+ });
+});
diff --git a/packages/pluggableWidgets/skiplink-web/e2e/package.json b/packages/pluggableWidgets/skiplink-web/e2e/package.json
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/packages/pluggableWidgets/skiplink-web/eslint.config.mjs b/packages/pluggableWidgets/skiplink-web/eslint.config.mjs
new file mode 100644
index 0000000000..ed68ae9e78
--- /dev/null
+++ b/packages/pluggableWidgets/skiplink-web/eslint.config.mjs
@@ -0,0 +1,3 @@
+import config from "@mendix/eslint-config-web-widgets/widget-ts.mjs";
+
+export default config;
diff --git a/packages/pluggableWidgets/skiplink-web/jest.config.js b/packages/pluggableWidgets/skiplink-web/jest.config.js
new file mode 100644
index 0000000000..88999d5568
--- /dev/null
+++ b/packages/pluggableWidgets/skiplink-web/jest.config.js
@@ -0,0 +1,3 @@
+module.exports = {
+ ...require("@mendix/pluggable-widgets-tools/test-config/jest.enzyme-free.config.js")
+};
diff --git a/packages/pluggableWidgets/skiplink-web/package.json b/packages/pluggableWidgets/skiplink-web/package.json
new file mode 100644
index 0000000000..dd660ba034
--- /dev/null
+++ b/packages/pluggableWidgets/skiplink-web/package.json
@@ -0,0 +1,55 @@
+{
+ "name": "@mendix/skiplink-web",
+ "widgetName": "SkipLink",
+ "version": "1.0.0",
+ "description": "Adds a skip link to the top of the page for accessibility.",
+ "copyright": "© Mendix Technology BV 2025. All rights reserved.",
+ "license": "Apache-2.0",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/mendix/web-widgets.git"
+ },
+ "config": {},
+ "mxpackage": {
+ "name": "SkipLink",
+ "type": "widget",
+ "mpkName": "com.mendix.widget.web.SkipLink.mpk"
+ },
+ "packagePath": "com.mendix.widget.web",
+ "marketplace": {
+ "minimumMXVersion": "11.1.0",
+ "appNumber": 119999,
+ "appName": "SkipLink",
+ "reactReady": true
+ },
+ "scripts": {
+ "build": "pluggable-widgets-tools build:web",
+ "create-gh-release": "rui-create-gh-release",
+ "create-translation": "rui-create-translation",
+ "dev": "pluggable-widgets-tools start:web",
+ "e2e": "run-e2e ci",
+ "e2edev": "run-e2e dev --with-preps",
+ "format": "prettier --ignore-path ./node_modules/@mendix/prettier-config-web-widgets/global-prettierignore --write .",
+ "lint": "eslint src/ package.json",
+ "publish-marketplace": "rui-publish-marketplace",
+ "release": "pluggable-widgets-tools release:web",
+ "start": "pluggable-widgets-tools start:server",
+ "test": "jest --projects jest.config.js",
+ "update-changelog": "rui-update-changelog-widget",
+ "verify": "rui-verify-package-format"
+ },
+ "dependencies": {
+ "@floating-ui/react": "^0.26.27",
+ "@mendix/widget-plugin-component-kit": "workspace:*",
+ "classnames": "^2.5.1"
+ },
+ "devDependencies": {
+ "@mendix/automation-utils": "workspace:*",
+ "@mendix/eslint-config-web-widgets": "workspace:*",
+ "@mendix/pluggable-widgets-tools": "*",
+ "@mendix/prettier-config-web-widgets": "workspace:*",
+ "@mendix/run-e2e": "workspace:*",
+ "@mendix/widget-plugin-hooks": "workspace:*",
+ "@mendix/widget-plugin-platform": "workspace:*"
+ }
+}
diff --git a/packages/pluggableWidgets/skiplink-web/playwright.config.cjs b/packages/pluggableWidgets/skiplink-web/playwright.config.cjs
new file mode 100644
index 0000000000..29045fc372
--- /dev/null
+++ b/packages/pluggableWidgets/skiplink-web/playwright.config.cjs
@@ -0,0 +1 @@
+module.exports = require("@mendix/run-e2e/playwright.config.cjs");
diff --git a/packages/pluggableWidgets/skiplink-web/src/SkipLink.editorConfig.ts b/packages/pluggableWidgets/skiplink-web/src/SkipLink.editorConfig.ts
new file mode 100644
index 0000000000..6fa0fe3dbb
--- /dev/null
+++ b/packages/pluggableWidgets/skiplink-web/src/SkipLink.editorConfig.ts
@@ -0,0 +1,80 @@
+import { Problem, Properties } from "@mendix/pluggable-widgets-tools";
+import {
+ StructurePreviewProps,
+ RowLayoutProps,
+ ContainerProps,
+ TextProps,
+ structurePreviewPalette
+} from "@mendix/widget-plugin-platform/preview/structure-preview-api";
+
+export function getProperties(defaultValues: Properties): Properties {
+ // No conditional properties for skiplink, but function provided for consistency
+ return defaultValues;
+}
+
+export function check(values: any): Problem[] {
+ const errors: Problem[] = [];
+ if (!values.linkText) {
+ errors.push({
+ property: "linkText",
+ message: "Link text is required"
+ });
+ }
+ if (!values.mainContentId) {
+ errors.push({
+ property: "mainContentId",
+ message: "Main content ID is required"
+ });
+ }
+ return errors;
+}
+
+export function getPreview(values: any, isDarkMode: boolean): StructurePreviewProps | null {
+ const palette = structurePreviewPalette[isDarkMode ? "dark" : "light"];
+ const titleHeader: RowLayoutProps = {
+ type: "RowLayout",
+ columnSize: "grow",
+ backgroundColor: palette.background.topbarStandard,
+ borders: true,
+ borderWidth: 1,
+ children: [
+ {
+ type: "Container",
+ padding: 4,
+ children: [
+ {
+ type: "Text",
+ content: "SkipLink",
+ fontColor: palette.text.secondary
+ } as TextProps
+ ]
+ }
+ ]
+ };
+ const linkContent: RowLayoutProps = {
+ type: "RowLayout",
+ columnSize: "grow",
+ borders: true,
+ padding: 0,
+ children: [
+ {
+ type: "Container",
+ padding: 6,
+ children: [
+ {
+ type: "Text",
+ content: values.linkText || "Skip to main content",
+ fontSize: 14,
+ fontColor: palette.text.primary,
+ bold: true
+ } as TextProps
+ ]
+ }
+ ]
+ };
+ return {
+ type: "Container",
+ borders: true,
+ children: [titleHeader, linkContent]
+ } as ContainerProps;
+}
diff --git a/packages/pluggableWidgets/skiplink-web/src/SkipLink.editorPreview.tsx b/packages/pluggableWidgets/skiplink-web/src/SkipLink.editorPreview.tsx
new file mode 100644
index 0000000000..543e043658
--- /dev/null
+++ b/packages/pluggableWidgets/skiplink-web/src/SkipLink.editorPreview.tsx
@@ -0,0 +1,34 @@
+import { createElement, ReactElement } from "react";
+import { SkipLinkPreviewProps } from "../typings/SkipLinkProps";
+
+export const preview = (props: SkipLinkPreviewProps): ReactElement => {
+ if (props.renderMode === "xray") {
+ return (
+
+ );
+ }
+ return ;
+};
+
+export function getPreviewCss(): string {
+ return require("./ui/SkipLink.scss");
+}
diff --git a/packages/pluggableWidgets/skiplink-web/src/SkipLink.tsx b/packages/pluggableWidgets/skiplink-web/src/SkipLink.tsx
new file mode 100644
index 0000000000..2c748c0263
--- /dev/null
+++ b/packages/pluggableWidgets/skiplink-web/src/SkipLink.tsx
@@ -0,0 +1,66 @@
+import "./ui/SkipLink.scss";
+import { useEffect } from "react";
+
+export interface SkipLinkProps {
+ /**
+ * The text displayed for the skip link.
+ */
+ linkText: string;
+ /**
+ * The id of the main content element to jump to.
+ */
+ mainContentId: string;
+}
+
+/**
+ * Inserts a skip link as the first child of the element with ID 'root'.
+ * When activated, focus is programmatically set to the main content.
+ */
+export function SkipLink({ linkText, mainContentId }: SkipLinkProps): null {
+ useEffect(() => {
+ // Create the skip link element
+ const link = document.createElement("a");
+ link.href = `#${mainContentId}`;
+ link.className = "skip-link";
+ link.textContent = linkText;
+ link.tabIndex = 0;
+
+ // Handler to move focus to the main content
+ function handleClick(event: MouseEvent) {
+ event.preventDefault();
+ const main = document.getElementById(mainContentId);
+ if (main) {
+ // Store previous tabindex
+ const prevTabIndex = main.getAttribute("tabindex");
+ // Ensure main is focusable
+ if (!main.hasAttribute("tabindex")) {
+ main.setAttribute("tabindex", "-1");
+ }
+ main.focus();
+ // Clean up tabindex if it was not present before
+ if (prevTabIndex === null) {
+ main.addEventListener("blur", () => main.removeAttribute("tabindex"), { once: true });
+ }
+ }
+ }
+
+ link.addEventListener("click", handleClick);
+
+ // Insert as the first child of the element with ID 'root'
+ const root = document.getElementById("root");
+ if (root) {
+ root.insertBefore(link, root.firstChild);
+ }
+
+ // Cleanup on unmount
+ return () => {
+ link.removeEventListener("click", handleClick);
+ if (link.parentNode) {
+ link.parentNode.removeChild(link);
+ }
+ };
+ }, [linkText, mainContentId]);
+
+ // This component does not render anything in the React tree
+ return null;
+}
diff --git a/packages/pluggableWidgets/skiplink-web/src/SkipLink.xml b/packages/pluggableWidgets/skiplink-web/src/SkipLink.xml
new file mode 100644
index 0000000000..9769fe7d01
--- /dev/null
+++ b/packages/pluggableWidgets/skiplink-web/src/SkipLink.xml
@@ -0,0 +1,20 @@
+
+
+ SkipLink
+ A skip link for accessibility, allowing users to jump directly to the main content.
+ Accessibility
+ Accessibility
+ https://docs.mendix.com/appstore/widgets/skiplink
+
+
+
+ Link text
+ The text displayed for the skip link.
+
+
+ Main content ID
+ The id of the main content element to jump to.
+
+
+
+
diff --git a/packages/pluggableWidgets/skiplink-web/src/package.xml b/packages/pluggableWidgets/skiplink-web/src/package.xml
new file mode 100644
index 0000000000..811a87e4ab
--- /dev/null
+++ b/packages/pluggableWidgets/skiplink-web/src/package.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/pluggableWidgets/skiplink-web/src/ui/SkipLink.scss b/packages/pluggableWidgets/skiplink-web/src/ui/SkipLink.scss
new file mode 100644
index 0000000000..5a613ceb39
--- /dev/null
+++ b/packages/pluggableWidgets/skiplink-web/src/ui/SkipLink.scss
@@ -0,0 +1,20 @@
+.skip-link {
+ position: absolute;
+ top: 0;
+ left: 0;
+ background: #fff;
+ color: #0078d4;
+ padding: 8px 16px;
+ z-index: 1000;
+ transform: translateY(-120%);
+ transition: transform 0.2s;
+ text-decoration: none;
+ border: 2px solid #0078d4;
+ border-radius: 4px;
+ font-weight: bold;
+}
+
+.skip-link:focus {
+ transform: translateY(0);
+ outline: none;
+}
diff --git a/packages/pluggableWidgets/skiplink-web/tsconfig.json b/packages/pluggableWidgets/skiplink-web/tsconfig.json
new file mode 100644
index 0000000000..a2a5b87e60
--- /dev/null
+++ b/packages/pluggableWidgets/skiplink-web/tsconfig.json
@@ -0,0 +1,31 @@
+{
+ "include": ["./src", "./typings"],
+ "compilerOptions": {
+ "baseUrl": "./",
+ "noEmitOnError": true,
+ "sourceMap": true,
+ "module": "esnext",
+ "target": "es6",
+ "lib": ["esnext", "dom"],
+ "types": ["jest", "node"],
+ "moduleResolution": "node",
+ "declaration": false,
+ "noLib": false,
+ "forceConsistentCasingInFileNames": true,
+ "noFallthroughCasesInSwitch": true,
+ "strict": true,
+ "strictFunctionTypes": false,
+ "skipLibCheck": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "jsx": "react",
+ "jsxFactory": "createElement",
+ "allowSyntheticDefaultImports": true,
+ "esModuleInterop": true,
+ "useUnknownInCatchVariables": false,
+ "exactOptionalPropertyTypes": false,
+ "paths": {
+ "react-hot-loader/root": ["./hot-typescript.ts"]
+ }
+ }
+}
diff --git a/packages/pluggableWidgets/skiplink-web/typings/SkipLinkProps.d.ts b/packages/pluggableWidgets/skiplink-web/typings/SkipLinkProps.d.ts
new file mode 100644
index 0000000000..dd4d4c8a82
--- /dev/null
+++ b/packages/pluggableWidgets/skiplink-web/typings/SkipLinkProps.d.ts
@@ -0,0 +1,30 @@
+/**
+ * This file was generated from SkipLink.xml
+ * WARNING: All changes made to this file will be overwritten
+ * @author Mendix Widgets Framework Team
+ */
+import { CSSProperties } from "react";
+
+export interface SkipLinkContainerProps {
+ name: string;
+ class: string;
+ style?: CSSProperties;
+ tabIndex?: number;
+ linkText: string;
+ mainContentId: string;
+}
+
+export interface SkipLinkPreviewProps {
+ /**
+ * @deprecated Deprecated since version 9.18.0. Please use class property instead.
+ */
+ className: string;
+ class: string;
+ style: string;
+ styleObject?: CSSProperties;
+ readOnly: boolean;
+ renderMode: "design" | "xray" | "structure";
+ translate: (text: string) => string;
+ linkText: string;
+ mainContentId: string;
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 40d480cc17..4f024987f7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2138,6 +2138,40 @@ importers:
specifier: ^7.0.3
version: 7.0.3
+ packages/pluggableWidgets/skiplink-web:
+ dependencies:
+ '@floating-ui/react':
+ specifier: ^0.26.27
+ version: 0.26.27(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
+ '@mendix/widget-plugin-component-kit':
+ specifier: workspace:*
+ version: link:../../shared/widget-plugin-component-kit
+ classnames:
+ specifier: ^2.5.1
+ version: 2.5.1
+ devDependencies:
+ '@mendix/automation-utils':
+ specifier: workspace:*
+ version: link:../../../automation/utils
+ '@mendix/eslint-config-web-widgets':
+ specifier: workspace:*
+ version: link:../../shared/eslint-config-web-widgets
+ '@mendix/pluggable-widgets-tools':
+ specifier: 10.21.2
+ version: 10.21.2(@jest/transform@29.7.0)(@jest/types@29.6.3)(@swc/core@1.7.26(@swc/helpers@0.5.15))(@types/babel__core@7.20.3)(@types/node@22.14.1)(picomatch@4.0.2)(react-dom@18.2.0(react@18.2.0))(react-native@0.75.3(@babel/core@7.27.4)(@babel/preset-env@7.26.9(@babel/core@7.27.4))(@types/react@18.2.36)(react@18.2.0))(react@18.2.0)(tslib@2.8.1)
+ '@mendix/prettier-config-web-widgets':
+ specifier: workspace:*
+ version: link:../../shared/prettier-config-web-widgets
+ '@mendix/run-e2e':
+ specifier: workspace:*
+ version: link:../../../automation/run-e2e
+ '@mendix/widget-plugin-hooks':
+ specifier: workspace:*
+ version: link:../../shared/widget-plugin-hooks
+ '@mendix/widget-plugin-platform':
+ specifier: workspace:*
+ version: link:../../shared/widget-plugin-platform
+
packages/pluggableWidgets/slider-web:
dependencies:
'@mendix/widget-plugin-component-kit':
@@ -17602,7 +17636,7 @@ snapshots:
dependencies:
array-union: 2.1.0
dir-glob: 3.0.1
- fast-glob: 3.3.2
+ fast-glob: 3.3.3
ignore: 5.3.2
merge2: 1.4.1
slash: 3.0.0