diff --git a/package.json b/package.json
index 2594c2715..03f954f37 100644
--- a/package.json
+++ b/package.json
@@ -42,6 +42,7 @@
"@oku-ui/collapsible": "workspace:^",
"@oku-ui/label": "workspace:^",
"@oku-ui/popper": "workspace:^",
+ "@oku-ui/presence": "workspace:^",
"@oku-ui/primitive": "workspace:^",
"@oku-ui/progress": "workspace:^",
"@oku-ui/provide": "workspace:^",
diff --git a/packages/components/presence/README.md b/packages/components/presence/README.md
new file mode 100644
index 000000000..76b7c1e81
--- /dev/null
+++ b/packages/components/presence/README.md
@@ -0,0 +1,11 @@
+# `presence`
+
+## Installation
+
+```sh
+$ pnpm add @oku-ui/presence
+```
+
+## Usage
+
+soon docs
\ No newline at end of file
diff --git a/packages/components/presence/build.config.ts b/packages/components/presence/build.config.ts
new file mode 100644
index 000000000..b972b9a78
--- /dev/null
+++ b/packages/components/presence/build.config.ts
@@ -0,0 +1,12 @@
+import { defineBuildConfig } from 'unbuild'
+
+export default defineBuildConfig({
+ entries: [
+ {
+ builder: 'mkdist',
+ input: './src/',
+ pattern: ['**/!(*.test|*.stories).ts'],
+ },
+ ],
+ declaration: true,
+})
diff --git a/packages/components/presence/package.json b/packages/components/presence/package.json
new file mode 100644
index 000000000..630852ace
--- /dev/null
+++ b/packages/components/presence/package.json
@@ -0,0 +1,49 @@
+{
+ "name": "@oku-ui/presence",
+ "type": "module",
+ "version": "0.1.0",
+ "license": "MIT",
+ "source": "src/index.ts",
+ "funding": "https://github.com/sponsors/productdevbook",
+ "homepage": "https://oku-ui.com/primitives",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/oku-ui/primitives.git",
+ "directory": "packages/components/presence"
+ },
+ "bugs": {
+ "url": "https://github.com/oku-ui/primitives/issues"
+ },
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.mjs"
+ }
+ },
+ "module": "./dist/index.mjs",
+ "types": "./dist/index.d.ts",
+ "files": [
+ "dist"
+ ],
+ "engines": {
+ "node": ">=18"
+ },
+ "scripts": {
+ "build": "tsup",
+ "dev": "tsup --watch"
+ },
+ "peerDependencies": {
+ "vue": "^3.3.0"
+ },
+ "dependencies": {
+ "@oku-ui/primitive": "latest",
+ "@oku-ui/use-composable": "latest",
+ "@oku-ui/utils": "latest"
+ },
+ "devDependencies": {
+ "tsconfig": "workspace:^"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/packages/components/presence/src/index.ts b/packages/components/presence/src/index.ts
new file mode 100644
index 000000000..f8bda0578
--- /dev/null
+++ b/packages/components/presence/src/index.ts
@@ -0,0 +1,3 @@
+export {
+ OkuPresence,
+} from './presence'
diff --git a/packages/components/presence/src/presence.test.ts b/packages/components/presence/src/presence.test.ts
new file mode 100644
index 000000000..43de0e96b
--- /dev/null
+++ b/packages/components/presence/src/presence.test.ts
@@ -0,0 +1,117 @@
+import { describe, expect, it } from 'vitest'
+import { type Component, ref } from 'vue'
+import { mount } from '@vue/test-utils'
+import { OkuPresence } from './presence'
+
+describe('presence', async () => {
+ it('close content', async () => {
+ const component = {
+ components: {
+ OkuPresence,
+ },
+ template: `
+
+
+
+
+ content
+
+
+
+ `,
+ setup() {
+ const open = ref(false)
+ const toggle = () => {
+ open.value = !open.value
+ }
+ return {
+ open,
+ toggle,
+ }
+ },
+ } as Component
+ const wrapper = mount(component, {})
+ expect(wrapper.html()).toContain(`
+
+
`)
+ })
+
+ it('open content', async () => {
+ const component = {
+ components: {
+ OkuPresence,
+ },
+ template: `
+
+
+
+
+ content
+
+
+
+ `,
+ setup() {
+ const open = ref(false)
+ const toggle = () => {
+ open.value = !open.value
+ }
+ return {
+ open,
+ toggle,
+ }
+ },
+ } as Component
+ const wrapper = mount(component, {})
+ await wrapper.find('button').trigger('click')
+ expect(wrapper.html()).toContain(`
+
content
+
`)
+ })
+
+ it('open content', async () => {
+ const component = {
+ components: {
+ OkuPresence,
+ },
+ template: `
+
+
+
+
+ content
+
+
+
+ `,
+ setup() {
+ const open = ref(false)
+ const toggle = () => {
+ open.value = !open.value
+ }
+ return {
+ open,
+ toggle,
+ }
+ },
+ } as Component
+ const wrapper = mount(component, {})
+ expect(wrapper.html()).toContain(`
+
+
`)
+
+ await wrapper.find('button').trigger('click')
+
+ expect(wrapper.html()).toContain(`
+
content
+
`)
+ })
+
+ // TODO: add transition test
+})
diff --git a/packages/components/presence/src/presence.ts b/packages/components/presence/src/presence.ts
new file mode 100644
index 000000000..d42a12e1f
--- /dev/null
+++ b/packages/components/presence/src/presence.ts
@@ -0,0 +1,74 @@
+import type { Directive } from 'vue'
+import { defineComponent, h, ref, toRefs, withDirectives } from 'vue'
+import { syncRef } from '@oku-ui/use-composable'
+import { usePresence } from './usePresence'
+
+interface PresenceProps {
+ present: boolean
+}
+
+const NAME = 'OkuPresence'
+
+const presence = defineComponent({
+ name: NAME,
+ inheritAttrs: false,
+ props: {
+ present: {
+ type: Boolean,
+ default: false,
+ },
+ },
+ setup(props, { slots, attrs }) {
+ const { present } = toRefs(props)
+ const elementRef = ref()
+
+ const element: Directive = {
+ created(el) {
+ const { isPresent } = usePresence(present, el)
+ syncRef(isPresent, elementRef, { direction: 'ltr' })
+ },
+ }
+
+ return () => {
+ const children = slots.default?.()
+
+ if (children?.length === 1) {
+ const [firstChild] = children || []
+
+ const directVNodeChildren = withDirectives(
+ h(
+ firstChild,
+ {
+ present: present.value,
+ ...attrs,
+ },
+ ),
+ [
+ [element],
+ ])
+
+ return present.value ? directVNodeChildren : null
+ }
+ else {
+ throw new Error(
+ [
+ `Now you can only pass one child to \`${NAME}\`.`,
+ '',
+ 'Note: All components accepting `Presence` expect only one direct child of valid VNode type.',
+ 'You can apply a few solutions:',
+ [
+ 'Provide a single child element so that we can forward the props onto that element.',
+ 'Ensure the first child is an actual element instead of a raw text node or comment node.',
+ ]
+ .map(line => ` - ${line}`)
+ .join('\n'),
+ ].join('\n'),
+ )
+ }
+ }
+ },
+})
+
+const OkuPresence = presence as typeof presence & (new () => { $props: PresenceProps })
+
+export { OkuPresence }
diff --git a/packages/components/presence/src/stories/PresenceDemo.vue b/packages/components/presence/src/stories/PresenceDemo.vue
new file mode 100644
index 000000000..d121a9328
--- /dev/null
+++ b/packages/components/presence/src/stories/PresenceDemo.vue
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
+
+
+ content
+
+
+
+
+
+
+
+
+ content
+
+
+
+
+
+
+
+
+
diff --git a/packages/components/presence/src/stories/presence.stories.ts b/packages/components/presence/src/stories/presence.stories.ts
new file mode 100644
index 000000000..1e3fd2f75
--- /dev/null
+++ b/packages/components/presence/src/stories/presence.stories.ts
@@ -0,0 +1,73 @@
+import type { Meta, StoryObj } from '@storybook/vue3'
+
+import type { OkuPresenceProps } from './PresenceDemo.vue'
+import OkuPresenceComponent from './PresenceDemo.vue'
+
+interface StoryProps extends OkuPresenceProps {
+}
+
+const meta = {
+ title: 'Utilities/Presence',
+ component: OkuPresenceComponent,
+ args: {
+ template: '#1',
+ },
+ argTypes: {
+ template: {
+ control: 'text',
+ },
+ },
+ tags: ['autodocs'],
+} satisfies Meta & {
+ args: StoryProps
+}
+
+export default meta
+type Story = StoryObj & {
+ args: StoryProps
+}
+
+export const Styled: Story = {
+ args: {
+ template: '#1',
+ },
+ render: (args: any) => ({
+ components: { OkuPresenceComponent },
+ setup() {
+ return { args }
+ },
+ template: `
+
+ `,
+ }),
+}
+
+export const Transition: Story = {
+ args: {
+ template: '#2',
+ },
+ render: (args: any) => ({
+ components: { OkuPresenceComponent },
+ setup() {
+ return { args }
+ },
+ template: `
+
+ `,
+ }),
+}
+
+export const WithDeferredMountAnimation: Story = {
+ args: {
+ template: '#3',
+ },
+ render: (args: any) => ({
+ components: { OkuPresenceComponent },
+ setup() {
+ return { args }
+ },
+ template: `
+
+ `,
+ }),
+}
diff --git a/packages/components/presence/src/usePresence.ts b/packages/components/presence/src/usePresence.ts
new file mode 100644
index 000000000..f941051a9
--- /dev/null
+++ b/packages/components/presence/src/usePresence.ts
@@ -0,0 +1,125 @@
+import { type Ref, computed, nextTick, ref, watch } from 'vue'
+import { useStateMachine } from './useStateMachine'
+
+function getAnimationName(styles?: CSSStyleDeclaration) {
+ return styles?.animationName || 'none'
+}
+
+export function usePresence(present: Ref, el: HTMLElement) {
+ const stylesRef = ref({} as any)
+ const prevPresentRef = ref(present.value)
+ const prevAnimationNameRef = ref('none')
+ const initialState = present.value ? 'mounted' : 'unmounted'
+
+ const { state, dispatch: send } = useStateMachine(initialState, {
+ mounted: {
+ UNMOUNT: 'unmounted',
+ ANIMATION_OUT: 'unmountSuspended',
+ },
+ unmountSuspended: {
+ MOUNT: 'mounted',
+ ANIMATION_END: 'unmounted',
+ },
+ unmounted: {
+ MOUNT: 'mounted',
+ },
+ })
+
+ watch(state, () => {
+ const currentAnimationName = getAnimationName(stylesRef.value)
+ prevAnimationNameRef.value = state.value === 'mounted' ? currentAnimationName : 'none'
+ })
+
+ watch([present], async () => {
+ const styles = stylesRef.value
+ const wasPresent = prevPresentRef.value
+ const hasPresentChanged = wasPresent !== present.value
+
+ await nextTick()
+
+ if (hasPresentChanged) {
+ const prevAnimationName = prevAnimationNameRef.value
+ const currentAnimationName = getAnimationName(styles)
+
+ if (present.value) {
+ send('MOUNT')
+ }
+ else if (currentAnimationName === 'none'
+ || styles?.display === 'none'
+ ) {
+ // If there is no exit animation or the element is hidden, animations won't run
+ // so we unmount instantly
+ send('UNMOUNT')
+ }
+ else {
+ /**
+ * When `present` changes to `false`, we check changes to animation-name to
+ * determine whether an animation has started. We chose this approach (reading
+ * computed styles) because there is no `animationrun` event and `animationstart`
+ * fires after `animation-delay` has expired which would be too late.
+ */
+ const isAnimating = prevAnimationName !== currentAnimationName
+ if (wasPresent && isAnimating)
+ send('ANIMATION_OUT')
+ else
+ send('UNMOUNT')
+ }
+
+ prevPresentRef.value = present.value
+ }
+ })
+
+ watch(() => el, () => {
+ if (el) {
+ /**
+ * Triggering an ANIMATION_OUT during an ANIMATION_IN will fire an `animationcancel`
+ * event for ANIMATION_IN after we have entered `unmountSuspended` state. So, we
+ * make sure we only trigger ANIMATION_END for the currently active animation.
+ */
+ const handleAnimationEnd = async (event: AnimationEvent) => {
+ const currentAnimationName = getAnimationName(stylesRef.value)
+ const isCurrentAnimation = currentAnimationName.includes(
+ event.animationName,
+ )
+ if (event.target === el && isCurrentAnimation) {
+ // With React 18 concurrency this update is applied
+ // a frame after the animation ends, creating a flash of visible content.
+ // By manually flushing we ensure they sync within a frame, removing the flash.
+ send('ANIMATION_END')
+ }
+ }
+ const handleAnimationStart = (event: AnimationEvent) => {
+ if (event.target === el)
+ // if animation occurred, store its name as the previous animation.
+ prevAnimationNameRef.value = getAnimationName(stylesRef.value)
+ }
+ el.addEventListener('animationstart', handleAnimationStart)
+ el.addEventListener('animationcancel', handleAnimationEnd)
+ el.addEventListener('animationend', handleAnimationEnd)
+
+ return () => {
+ if (el) {
+ el.removeEventListener('animationstart', handleAnimationStart)
+ el.removeEventListener('animationcancel', handleAnimationEnd)
+ el.removeEventListener('animationend', handleAnimationEnd)
+ }
+ }
+ }
+ else {
+ // Transition to the unmounted state if the el is removed prematurely.
+ // We avoid doing so during cleanup as the el may change but still exist.
+ send('ANIMATION_END')
+ }
+ })
+
+ const isPresent = computed(() =>
+ ['mounted', 'unmountSuspended'].includes(state.value),
+ )
+
+ if (el)
+ stylesRef.value = getComputedStyle(el)
+
+ return {
+ isPresent,
+ }
+}
diff --git a/packages/components/presence/src/useStateMachine.ts b/packages/components/presence/src/useStateMachine.ts
new file mode 100644
index 000000000..a2879e738
--- /dev/null
+++ b/packages/components/presence/src/useStateMachine.ts
@@ -0,0 +1,36 @@
+import { ref } from 'vue'
+
+type Machine = { [k: string]: { [k: string]: S } }
+type MachineState = keyof T
+type MachineEvent = keyof UnionToIntersection
+
+// 🤯 https://fettblog.eu/typescript-union-to-intersection/
+type UnionToIntersection = (T extends any ? (x: T) => any : never) extends (
+ x: infer R
+) => any
+ ? R
+ : never
+
+export function useStateMachine(
+ initialState: MachineState,
+ machine: M & Machine>,
+) {
+ const state = ref(initialState)
+
+ const reducer = (event: MachineEvent) => {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-expect-error
+ const nextState = machine[state.value][event]
+
+ return nextState ?? state.value
+ }
+
+ const dispatch = (event: MachineEvent) => {
+ state.value = reducer(event)
+ }
+
+ return {
+ state,
+ dispatch,
+ }
+}
diff --git a/packages/components/presence/tsconfig.json b/packages/components/presence/tsconfig.json
new file mode 100644
index 000000000..b8dfa9041
--- /dev/null
+++ b/packages/components/presence/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "tsconfig/node16.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist"
+ },
+ "include": [
+ "src"
+ ]
+}
diff --git a/packages/components/presence/tsup.config.ts b/packages/components/presence/tsup.config.ts
new file mode 100644
index 000000000..a2f7a0d8b
--- /dev/null
+++ b/packages/components/presence/tsup.config.ts
@@ -0,0 +1,22 @@
+import { defineConfig } from 'tsup'
+import pkg from './package.json'
+
+const external = [
+ ...Object.keys(pkg.dependencies || {}),
+ ...Object.keys(pkg.peerDependencies || {}),
+]
+
+export default defineConfig((options) => {
+ return [
+ {
+ ...options,
+ entryPoints: ['src/index.ts'],
+ external,
+ dts: true,
+ clean: true,
+ target: 'node16',
+ format: ['esm'],
+ outExtension: () => ({ js: '.mjs' }),
+ },
+ ]
+})
diff --git a/packages/core/use-composable/src/index.ts b/packages/core/use-composable/src/index.ts
index 8959420b8..5c993ec0f 100644
--- a/packages/core/use-composable/src/index.ts
+++ b/packages/core/use-composable/src/index.ts
@@ -1,4 +1,4 @@
-import { computedEager } from '@vueuse/core'
+import { computedEager, syncRef } from '@vueuse/core'
export { useControllable } from './useControllable'
export { useCallbackRef } from './useCallbackRef'
@@ -9,4 +9,4 @@ export { unrefElement } from './unrefElement'
export { useId } from './useId'
export type { MaybeComputedElementRef } from './unrefElement'
-export { computedEager }
+export { computedEager, syncRef }
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 52f821fb0..97dc3640a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -40,6 +40,9 @@ importers:
'@oku-ui/popper':
specifier: workspace:^
version: link:packages/components/popper
+ '@oku-ui/presence':
+ specifier: workspace:^
+ version: link:packages/components/presence
'@oku-ui/primitive':
specifier: workspace:^
version: link:packages/core/primitive
@@ -324,6 +327,25 @@ importers:
specifier: workspace:^
version: link:../../tsconfig
+ packages/components/presence:
+ dependencies:
+ '@oku-ui/primitive':
+ specifier: latest
+ version: link:../../core/primitive
+ '@oku-ui/use-composable':
+ specifier: latest
+ version: link:../../core/use-composable
+ '@oku-ui/utils':
+ specifier: latest
+ version: link:../../core/utils
+ vue:
+ specifier: ^3.3.0
+ version: 3.3.4
+ devDependencies:
+ tsconfig:
+ specifier: workspace:^
+ version: link:../../tsconfig
+
packages/components/progress:
dependencies:
'@oku-ui/primitive':