diff --git a/.changeset/beige-flies-mate.md b/.changeset/beige-flies-mate.md
new file mode 100644
index 00000000..3806497b
--- /dev/null
+++ b/.changeset/beige-flies-mate.md
@@ -0,0 +1,5 @@
+---
+"@skeletonlabs/floating-ui-svelte": minor
+---
+
+Added `useRole` hook
diff --git a/README.md b/README.md
index 9c4717c3..0b7275f1 100644
--- a/README.md
+++ b/README.md
@@ -163,7 +163,27 @@ This will ensure all event handlers will be registered rather being overruled by
### useRole
-(tbd)
+#### Usage
+
+```html
+
+
+
+
Tooltip
+```
+
+#### Options
+
+| Property | Description | Type | Default Value |
+| -------- | ----------- | ---- | ------------- |
+| enabled | Enables the interaction | boolean | true |
+| role | The role that the floating element should be | [AriaRole](https://floating-ui.com/docs/useRole#native-roles) \| [ComponentRole](https://floating-ui.com/docs/useRole#component-roles) | 'dialog' |
### useDismiss
diff --git a/src/lib/hooks/useRole/App.test.svelte b/src/lib/hooks/useRole/App.test.svelte
new file mode 100644
index 00000000..8cd3bf2a
--- /dev/null
+++ b/src/lib/hooks/useRole/App.test.svelte
@@ -0,0 +1,38 @@
+
+
+{open}
+
+{#if open}
+
+ Floating
+
+{/if}
diff --git a/src/lib/hooks/useRole/index.svelte.ts b/src/lib/hooks/useRole/index.svelte.ts
new file mode 100644
index 00000000..394add91
--- /dev/null
+++ b/src/lib/hooks/useRole/index.svelte.ts
@@ -0,0 +1,111 @@
+import { Map as ReactiveMap } from 'svelte/reactivity';
+import type { FloatingContext } from '../useFloating/index.svelte.js';
+import type { ElementProps } from '../useInteractions/index.svelte.js';
+
+type AriaRole = 'tooltip' | 'dialog' | 'alertdialog' | 'menu' | 'listbox' | 'grid' | 'tree';
+type ComponentRole = 'select' | 'label' | 'combobox';
+
+interface UseRoleOptions {
+ /**
+ * Whether the Hook is enabled, including all internal Effects and event
+ * handlers.
+ * @default true
+ */
+ enabled?: boolean;
+ /**
+ * The role of the floating element.
+ * @default 'dialog'
+ */
+ role?: AriaRole | ComponentRole;
+}
+
+const componentRoleToAriaRoleMap = new ReactiveMap([
+ ['select', 'listbox'],
+ ['combobox', 'listbox'],
+ ['label', false]
+]);
+
+function useRole(context: FloatingContext, options: UseRoleOptions = {}): ElementProps {
+ const enabled = $derived(options.enabled ?? true);
+ const role = $derived(options.role ?? 'dialog');
+
+ const ariaRole = $derived(
+ (componentRoleToAriaRoleMap.get(role) ?? role) as AriaRole | false | undefined
+ );
+
+ // FIXME: Uncomment the commented code once useId and useFloatingParentNodeId are implemented.
+ const referenceId = '123abc';
+ const parentId = undefined;
+ // const referenceId = useId();
+ // const parentId = useFloatingParentNodeId();
+
+ const isNested = parentId != null;
+
+ const elementProps: ElementProps = $derived.by(() => {
+ if (!enabled) {
+ return {};
+ }
+
+ const floatingProps = {
+ id: context.floatingId,
+ ...(ariaRole && { role: ariaRole })
+ };
+
+ if (ariaRole === 'tooltip' || role === 'label') {
+ return {
+ reference: {
+ [`aria-${role === 'label' ? 'labelledby' : 'describedby'}`]: context.open
+ ? context.floatingId
+ : undefined
+ },
+ floating: floatingProps
+ };
+ }
+
+ return {
+ reference: {
+ 'aria-expanded': context.open ? 'true' : 'false',
+ 'aria-haspopup': ariaRole === 'alertdialog' ? 'dialog' : ariaRole,
+ 'aria-controls': context.open ? context.floatingId : undefined,
+ ...(ariaRole === 'listbox' && { role: 'combobox' }),
+ ...(ariaRole === 'menu' && { id: referenceId }),
+ ...(ariaRole === 'menu' && isNested && { role: 'menuitem' }),
+ ...(role === 'select' && { 'aria-autocomplete': 'none' }),
+ ...(role === 'combobox' && { 'aria-autocomplete': 'list' })
+ },
+ floating: {
+ ...floatingProps,
+ ...(ariaRole === 'menu' && { 'aria-labelledby': referenceId })
+ },
+ item({ active, selected }) {
+ const commonProps = {
+ role: 'option',
+ ...(active && { id: `${context.floatingId}-option` })
+ };
+
+ // For `menu`, we are unable to tell if the item is a `menuitemradio`
+ // or `menuitemcheckbox`. For backwards-compatibility reasons, also
+ // avoid defaulting to `menuitem` as it may overwrite custom role props.
+ switch (role) {
+ case 'select':
+ return {
+ ...commonProps,
+ 'aria-selected': active && selected
+ };
+ case 'combobox': {
+ return {
+ ...commonProps,
+ ...(active && { 'aria-selected': true })
+ };
+ }
+ }
+
+ return {};
+ }
+ };
+ });
+
+ return elementProps;
+}
+
+export { useRole, type UseRoleOptions };
diff --git a/src/lib/hooks/useRole/index.test.svelte.ts b/src/lib/hooks/useRole/index.test.svelte.ts
new file mode 100644
index 00000000..0e4d59c1
--- /dev/null
+++ b/src/lib/hooks/useRole/index.test.svelte.ts
@@ -0,0 +1,72 @@
+import { describe, it, expect } from 'vitest';
+import { cleanup, render, screen } from '@testing-library/svelte';
+import App from './App.test.svelte';
+
+const ARIA_ROLES = ['grid', 'listbox', 'menu', 'tree', 'tooltip', 'alertdialog', 'dialog'] as const;
+
+describe('useRole', () => {
+ it('by default applies the "dialog" role to the floating element', () => {
+ render(App, { role: undefined, open: true });
+ expect(screen.queryByRole('dialog')).toBeInTheDocument();
+ cleanup();
+ });
+
+ for (const role of ARIA_ROLES) {
+ it(`applies the "${role}" role to the floating element`, () => {
+ render(App, { role, open: true });
+ expect(screen.queryByRole(role)).toBeInTheDocument();
+ cleanup();
+ });
+ }
+
+ describe('tooltip', () => {
+ it.skip('sets correct aria attributes based on the open state', async () => {
+ const { rerender } = render(App, { role: 'tooltip', open: true });
+
+ expect(screen.getByRole('button')).toHaveAttribute(
+ 'aria-describedby',
+ screen.getByRole('tooltip').getAttribute('id')
+ );
+
+ await rerender({ role: 'tooltip', open: false });
+
+ expect(screen.getByRole('buton')).not.toHaveAttribute('aria-describedby');
+
+ cleanup();
+ });
+ });
+
+ describe('label', () => {
+ it.skip('sets correct aria attributes based on the open state', async () => {
+ const { rerender } = render(App, { role: 'label', open: true });
+
+ expect(screen.getByRole('button')).toHaveAttribute(
+ 'aria-labelledby',
+ screen.getByRole('tooltip').getAttribute('id')
+ );
+
+ await rerender({ role: 'tooltip', open: false });
+
+ expect(screen.getByRole('buton')).not.toHaveAttribute('aria-labelledby');
+
+ cleanup();
+ });
+ });
+
+ describe('dialog', () => {
+ it.skip('sets correct aria attributes based on the open state', async () => {
+ const { rerender } = render(App, { role: 'dialog', open: false });
+
+ expect(screen.getByRole('button')).toHaveAttribute('aria-haspopup', 'dialog');
+ expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'false');
+
+ await rerender({ role: 'dialog', open: true });
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ expect(screen.getByRole('button')).toHaveAttribute(
+ 'aria-controls',
+ screen.getByRole('dialog').getAttribute('id')
+ );
+ });
+ });
+});