Skip to content
edmi-ui

Feed Post

Social post card: avatar header, body, attached index item and stat row.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
DL
Dewi Lestari @dewi · 2h

Rebalanced MAG4 after NVDAx drifted to 32.4%. Fees this week: 4.1 USDC.

MAG4 · Magnificent Four

$0.9998 · +0.53%

246Share
DL
Dewi Lestari @dewi · 2h

Rebalanced MAG4 after NVDAx drifted to 32.4%. Fees this week: 4.1 USDC.

MAG4 · Magnificent Four

$0.9998 · +0.53%

246Share
DL
Dewi Lestari @dewi · 2h

Rebalanced MAG4 after NVDAx drifted to 32.4%. Fees this week: 4.1 USDC.

MAG4 · Magnificent Four

$0.9998 · +0.53%

24 6 Share
Terminal window
npx shadcn@latest add @edmi-ui/feed-post
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/feed-post
Terminal window
yarn dlx shadcn@latest add @edmi-ui/feed-post
Terminal window
bunx shadcn@latest add @edmi-ui/feed-post

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/feed-post
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/feed-post
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/feed-post
Terminal window
bunx shadcn-vue@latest add @edmi-ui/feed-post

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/feed-post.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/feed-post.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/feed-post.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/feed-post.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

import {
FeedPost,
FeedPostContent,
FeedPostHeader,
FeedPostIndex,
} from "@/components/feed-post/feed-post";
import { Button } from "@/components/ui/button";
<FeedPost className="w-[460px] max-w-full">
<FeedPostHeader
name="Dewi Lestari"
handle="@dewi"
time="2h"
initials="DL"
/>
<FeedPostContent>
Rebalanced MAG4 after NVDAx drifted to 32.4%. Fees this week: 4.1 USDC.
</FeedPostContent>
<FeedPostIndex
icon={
<ChartLineIcon />
}
title="MAG4 · Magnificent Four"
description="$0.9998 · +0.53%"
action={<Button size="sm">Join</Button>}
/>
{/* … */}
</FeedPost>
<script setup lang="ts">
import { Button } from "@/components/ui/button";
import {
FeedPost,
FeedPostContent,
FeedPostFooter,
FeedPostHeader,
FeedPostIndex,
FeedPostStat,
} from "@/components/ui/feed-post";
</script>
<template>
<FeedPost class="w-[460px] max-w-full">
<FeedPostHeader name="Dewi Lestari" handle="@dewi" time="2h" initials="DL" />
<FeedPostContent>
Rebalanced MAG4 after NVDAx drifted to 32.4%. Fees this week: 4.1 USDC.
</FeedPostContent>
<FeedPostIndex title="MAG4 · Magnificent Four" description="$0.9998 · +0.53%">
<template #icon><ChartLineIcon /></template>
<template #action><Button size="sm">Join</Button></template>
</FeedPostIndex>
<FeedPostFooter>
<FeedPostStat><HeartIcon />24</FeedPostStat>
<FeedPostStat><ListIcon />6</FeedPostStat>
<FeedPostStat><GlobeIcon />Share</FeedPostStat>
</FeedPostFooter>
</FeedPost>
</template>
<script lang="ts">
import * as FeedPost from "$lib/components/ui/feed-post";
import { Button } from "$lib/components/ui/button";
</script>
<FeedPost.Root class="w-[460px] max-w-full">
<FeedPost.Header name="Dewi Lestari" handle="@dewi" time="2h" initials="DL" />
<FeedPost.Content>
Rebalanced MAG4 after NVDAx drifted to 32.4%. Fees this week: 4.1 USDC.
</FeedPost.Content>
<FeedPost.Index title="MAG4 · Magnificent Four" description="$0.9998 · +0.53%">
{#snippet icon()}
<ChartLineIcon />
{/snippet}
{#snippet action()}
<Button size="sm">Join</Button>
{/snippet}
</FeedPost.Index>
<!-- … -->
</FeedPost.Root>
registry/blocks/feed-post/feed-post.tsx
import { cn } from "cn";
import type * as React from "react";
import { DotsThreeOutlineIcon } from "@phosphor-icons/react";
import { Avatar, AvatarFallback, AvatarImage } from "@/registry/edmi/ui/avatar";
import { Button } from "@/registry/edmi/ui/button";
import { Card } from "@/registry/edmi/ui/card";
import type { Elevation } from "@/registry/edmi/ui/elevation";
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from "@/registry/edmi/ui/item";
type FeedPostProps = React.ComponentProps<typeof Card> & {
/** ✦ depth of the card: sunken -1, flat 0, raised +1, floating +2. */
elevation?: Elevation;
};
function FeedPost({ className, elevation, ...props }: FeedPostProps) {
return (
<Card
data-slot="feed-post"
elevation={elevation}
className={cn("gap-0 px-[18px] py-[18px]", className)}
{...props}
/>
);
}
type FeedPostHeaderProps = Omit<React.ComponentProps<"div">, "children"> & {
name: string;
handle?: string;
time?: string;
/** Image URL; falls back to `initials`. */
avatarSrc?: string;
initials?: string;
/** Right slot. Defaults to a ghost "more" icon button. */
actions?: React.ReactNode;
};
function FeedPostHeader({
className,
name,
handle,
time,
avatarSrc,
initials,
actions,
...props
}: FeedPostHeaderProps) {
return (
<div
data-slot="feed-post-header"
className={cn("flex items-center gap-2.5", className)}
{...props}
>
<Avatar className="size-9">
{avatarSrc ? <AvatarImage src={avatarSrc} alt={name} /> : null}
<AvatarFallback className="bg-linear-to-br from-info to-brand text-xs text-white">
{initials ?? name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1 truncate text-sm font-semibold">
{name}{" "}
{handle || time ? (
<span className="font-normal text-muted-foreground">
{[handle, time].filter(Boolean).join(" · ")}
</span>
) : null}
</div>
{actions ?? (
<Button variant="ghost" size="icon-sm" aria-label="More">
<DotsThreeOutlineIcon
/>
</Button>
)}
</div>
);
}
function FeedPostContent({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="feed-post-content"
className={cn("mt-3 text-sm leading-[1.55]", className)}
{...props}
/>
);
}
type FeedPostIndexProps = Omit<React.ComponentProps<typeof Item>, "title"> & {
title: React.ReactNode;
description?: React.ReactNode;
/** Leading icon element (an `IconPlaceholder`). */
icon?: React.ReactNode;
/** Trailing slot, e.g. a "Join" button. */
action?: React.ReactNode;
};
/** Attached index: an outline Item (media icon, title, mono description, action). */
function FeedPostIndex({
className,
title,
description,
icon,
action,
...props
}: FeedPostIndexProps) {
return (
<Item
data-slot="feed-post-index"
variant="outline"
size="sm"
className={cn("mt-3", className)}
{...props}
>
{icon ? <ItemMedia variant="icon">{icon}</ItemMedia> : null}
<ItemContent>
<ItemTitle>{title}</ItemTitle>
{description ? (
<ItemDescription className="font-mono text-[11.5px]">
{description}
</ItemDescription>
) : null}
</ItemContent>
{action ? <ItemActions>{action}</ItemActions> : null}
</Item>
);
}
function FeedPostFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="feed-post-footer"
className={cn(
"mt-3 flex items-center gap-[18px] text-[13px] text-muted-foreground",
className,
)}
{...props}
/>
);
}
function FeedPostStat({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="feed-post-stat"
className={cn(
"inline-flex items-center gap-1.5 [&_svg]:size-[15px]",
className,
)}
{...props}
/>
);
}
export {
FeedPost,
FeedPostContent,
FeedPostFooter,
FeedPostHeader,
FeedPostIndex,
FeedPostStat,
};
registry/ui/feed-post/FeedPost.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { Card } from '@/registry/edmi/ui/card'
import type { Elevation } from '@/registry/edmi/ui/elevation'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<{
/** ✦ depth of the card: sunken -1, flat 0, raised +1, floating +2 */
elevation?: Elevation
class?: HTMLAttributes['class']
}>()
</script>
<template>
<Card :elevation="elevation" data-slot="feed-post" :class="cn('gap-0 px-[18px] py-[18px]', props.class)">
<slot />
</Card>
</template>
registry/ui/feed-post/FeedPostContent.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<p data-slot="feed-post-content" :class="cn('mt-3 text-sm leading-[1.55]', props.class)">
<slot />
</p>
</template>
registry/ui/feed-post/FeedPostFooter.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<div
data-slot="feed-post-footer"
:class="cn('mt-3 flex items-center gap-[18px] text-[13px] text-muted-foreground', props.class)"
>
<slot />
</div>
</template>
registry/ui/feed-post/FeedPostHeader.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { computed } from 'vue'
import { MoreHorizontal } from '@lucide/vue'
import { Avatar, AvatarFallback, AvatarImage } from '@/registry/edmi/ui/avatar'
import { Button } from '@/registry/edmi/ui/button'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
name: string
handle?: string
time?: string
/** Image URL; falls back to `initials`. */
avatarSrc?: string
initials?: string
}>()
const meta = computed(() => [props.handle, props.time].filter(Boolean).join(' · '))
</script>
<template>
<div data-slot="feed-post-header" :class="cn('flex items-center gap-2.5', props.class)">
<Avatar class="size-9">
<AvatarImage v-if="avatarSrc" :src="avatarSrc" :alt="name" />
<AvatarFallback class="bg-linear-to-br from-info to-brand text-xs text-white">
{{ initials ?? name.slice(0, 2).toUpperCase() }}
</AvatarFallback>
</Avatar>
<div class="min-w-0 flex-1 truncate text-sm font-semibold">
{{ name }}
<span v-if="meta" class="font-normal text-muted-foreground">{{ meta }}</span>
</div>
<!-- Right slot. Defaults to a ghost "more" icon button. -->
<slot name="actions">
<Button variant="ghost" size="icon-sm" aria-label="More">
<MoreHorizontal />
</Button>
</slot>
</div>
</template>
registry/ui/feed-post/FeedPostIndex.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from '@/registry/edmi/ui/item'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
/** Title text, or use slot `title` for rich content. */
title?: string
description?: string
}>()
</script>
<template>
<!-- Attached index: an outline Item (media icon, title, mono description, action). -->
<Item data-slot="feed-post-index" variant="outline" size="sm" :class="cn('mt-3', props.class)">
<ItemMedia v-if="$slots.icon" variant="icon">
<slot name="icon" />
</ItemMedia>
<ItemContent>
<ItemTitle><slot name="title">{{ title }}</slot></ItemTitle>
<ItemDescription v-if="description" class="font-mono text-[11.5px]">
{{ description }}
</ItemDescription>
</ItemContent>
<ItemActions v-if="$slots.action">
<slot name="action" />
</ItemActions>
</Item>
</template>
registry/ui/feed-post/FeedPostStat.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>
<template>
<span data-slot="feed-post-stat" :class="cn('inline-flex items-center gap-1.5 [&_svg]:size-[15px]', props.class)">
<slot />
</span>
</template>
registry/ui/feed-post/index.ts
export { default as FeedPost } from "./FeedPost.vue";
export { default as FeedPostContent } from "./FeedPostContent.vue";
export { default as FeedPostFooter } from "./FeedPostFooter.vue";
export { default as FeedPostHeader } from "./FeedPostHeader.vue";
export { default as FeedPostIndex } from "./FeedPostIndex.vue";
export { default as FeedPostStat } from "./FeedPostStat.vue";
src/lib/registry/ui/feed-post/feed-post-content.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLParagraphElement> = $props();
</script>
<p data-slot="feed-post-content" class={cn("mt-3 text-sm leading-[1.55]", className)} {...restProps}>
{@render children?.()}
</p>
src/lib/registry/ui/feed-post/feed-post-footer.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> = $props();
</script>
<div
data-slot="feed-post-footer"
class={cn("mt-3 flex items-center gap-[18px] text-[13px] text-muted-foreground", className)}
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ui/feed-post/feed-post-header.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Avatar, AvatarFallback, AvatarImage } from "$lib/registry/ui/avatar/index.js";
import { Button } from "$lib/registry/ui/button/index.js";
import DotsThreeOutlineIcon from "phosphor-svelte/lib/DotsThreeOutline";
import type { Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
let {
class: className,
name,
handle,
time,
avatarSrc,
initials,
actions,
...restProps
}: Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
name: string;
handle?: string;
time?: string;
/** Image URL; falls back to `initials`. */
avatarSrc?: string;
initials?: string;
/** Right slot. Defaults to a ghost "more" icon button. */
actions?: Snippet;
} = $props();
const meta = $derived([handle, time].filter(Boolean).join(" · "));
</script>
<div data-slot="feed-post-header" class={cn("flex items-center gap-2.5", className)} {...restProps}>
<Avatar class="size-9">
{#if avatarSrc}<AvatarImage src={avatarSrc} alt={name} />{/if}
<AvatarFallback class="bg-linear-to-br from-info to-brand text-xs text-white">
{initials ?? name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div class="min-w-0 flex-1 truncate text-sm font-semibold">
{name}
{#if meta}<span class="font-normal text-muted-foreground">{meta}</span>{/if}
</div>
{#if actions}
{@render actions()}
{:else}
<Button variant="ghost" size="icon-sm" aria-label="More">
<DotsThreeOutlineIcon
/>
</Button>
{/if}
</div>
src/lib/registry/ui/feed-post/feed-post-index.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import * as Item from "$lib/registry/ui/item/index.js";
import type { Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
let {
class: className,
title,
description,
icon,
action,
...restProps
}: Omit<HTMLAttributes<HTMLDivElement>, "title" | "children"> & {
/** Title text, or a snippet for rich content. */
title: string | Snippet;
description?: string;
/** Leading icon (an `IconPlaceholder`). */
icon?: Snippet;
/** Trailing slot, e.g. a "Join" button. */
action?: Snippet;
} = $props();
</script>
<!-- Attached index: an outline Item (media icon, title, mono description, action). -->
<Item.Root data-slot="feed-post-index" variant="outline" size="sm" class={cn("mt-3", className)} {...restProps}>
{#if icon}
<Item.Media variant="icon">{@render icon()}</Item.Media>
{/if}
<Item.Content>
<Item.Title>{#if typeof title === "function"}{@render title()}{:else}{title}{/if}</Item.Title>
{#if description}
<Item.Description class="font-mono text-[11.5px]">{description}</Item.Description>
{/if}
</Item.Content>
{#if action}
<Item.Actions>{@render action()}</Item.Actions>
{/if}
</Item.Root>
src/lib/registry/ui/feed-post/feed-post-stat.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLSpanElement> = $props();
</script>
<span
data-slot="feed-post-stat"
class={cn("inline-flex items-center gap-1.5 [&_svg]:size-[15px]", className)}
{...restProps}
>
{@render children?.()}
</span>
src/lib/registry/ui/feed-post/feed-post.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Card } from "$lib/registry/ui/card/index.js";
import type { Elevation } from "$lib/registry/ui/elevation/index.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
elevation = "auto",
children,
...restProps
}: ComponentProps<typeof Card> & {
/** ✦ depth of the card: sunken -1, flat 0, raised +1, floating +2. */
elevation?: Elevation;
} = $props();
</script>
<Card bind:ref {elevation} data-slot="feed-post" class={cn("gap-0 px-[18px] py-[18px]", className)} {...restProps}>
{@render children?.()}
</Card>
src/lib/registry/ui/feed-post/index.ts
import Root from "./feed-post.svelte";
import Content from "./feed-post-content.svelte";
import Footer from "./feed-post-footer.svelte";
import Header from "./feed-post-header.svelte";
import Index from "./feed-post-index.svelte";
import Stat from "./feed-post-stat.svelte";
export {
Content,
Content as FeedPostContent,
Footer,
Footer as FeedPostFooter,
Header,
Header as FeedPostHeader,
Index,
Index as FeedPostIndex,
Root,
//
Root as FeedPost,
Stat,
Stat as FeedPostStat,
};

See the Elevation guide for the levels, roles and layered mode.

Flat by default. elevation is forwarded to the card: sunken (-1) a soft inset well, raised (+1) the bevel, floating (+2) the bevel plus one soft drop. In a layered ElevationProvider the card resolves to its role default (raised).

StackBlitz ↗StackBlitz ↗StackBlitz ↗

Flat (0)

DL
Dewi Lestari @dewi · 2h

Rebalanced MAG4 after NVDAx drifted to 32.4%. Fees this week: 4.1 USDC.

MAG4 · Magnificent Four

$0.9998 · +0.53%

246Share

Raised (+1)

DL
Dewi Lestari @dewi · 2h

Rebalanced MAG4 after NVDAx drifted to 32.4%. Fees this week: 4.1 USDC.

MAG4 · Magnificent Four

$0.9998 · +0.53%

246Share

Floating (+2)

DL
Dewi Lestari @dewi · 2h

Rebalanced MAG4 after NVDAx drifted to 32.4%. Fees this week: 4.1 USDC.

MAG4 · Magnificent Four

$0.9998 · +0.53%

246Share

Flat (0)

DL
Dewi Lestari @dewi · 2h

Rebalanced MAG4 after NVDAx drifted to 32.4%. Fees this week: 4.1 USDC.

MAG4 · Magnificent Four

$0.9998 · +0.53%

246Share

Raised (+1)

DL
Dewi Lestari @dewi · 2h

Rebalanced MAG4 after NVDAx drifted to 32.4%. Fees this week: 4.1 USDC.

MAG4 · Magnificent Four

$0.9998 · +0.53%

246Share

Floating (+2)

DL
Dewi Lestari @dewi · 2h

Rebalanced MAG4 after NVDAx drifted to 32.4%. Fees this week: 4.1 USDC.

MAG4 · Magnificent Four

$0.9998 · +0.53%

246Share

Flat (0)

DL
Dewi Lestari @dewi · 2h

Rebalanced MAG4 after NVDAx drifted to 32.4%. Fees this week: 4.1 USDC.

MAG4 · Magnificent Four

$0.9998 · +0.53%

24 6 Share

Raised (+1)

DL
Dewi Lestari @dewi · 2h

Rebalanced MAG4 after NVDAx drifted to 32.4%. Fees this week: 4.1 USDC.

MAG4 · Magnificent Four

$0.9998 · +0.53%

24 6 Share

Floating (+2)

DL
Dewi Lestari @dewi · 2h

Rebalanced MAG4 after NVDAx drifted to 32.4%. Fees this week: 4.1 USDC.

MAG4 · Magnificent Four

$0.9998 · +0.53%

24 6 Share
Part Prop Type Default
FeedPost …props Card props
elevation ✦ "auto" | "sunken" | "flat" | "raised" | "floating" "auto"
FeedPostHeader name string required
handle, time string none
avatarSrc, initials string; initials default to the first two letters of name none
actions React ReactNode; Vue slot actions; Svelte actions snippet ghost more button
FeedPostContent …props p props
FeedPostIndex title React ReactNode; Vue string or slot title; Svelte string | Snippet required
description ReactNode (Vue/Svelte: string) none
icon, action React ReactNode; Vue slots; Svelte snippets none
FeedPostFooter / FeedPostStat …props div / span props
  • Whole block is an Edmi addition (DESIGN §5 Patterns, board 12). Composed from: card, avatar, button, item.
  • Install: shadcn add @edmi-ui/feed-post.